From 0097d33fa111d05fb815fd8059f6397bb120562e Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 25 May 2026 00:00:31 +0800 Subject: [PATCH 01/54] feat(core): add tools agent insight type --- .../memory/core/data/DefaultInsightTypes.java | 18 ++++++++++++++++++ .../core/data/DefaultInsightTypesTest.java | 18 +++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/data/DefaultInsightTypes.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/data/DefaultInsightTypes.java index 1257bff5..9f0c652c 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/data/DefaultInsightTypes.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/data/DefaultInsightTypes.java @@ -183,6 +183,23 @@ public static MemoryInsightType resolutions() { MemoryScope.AGENT); } + public static MemoryInsightType tools() { + return new MemoryInsightType( + 28L, + "tools", + "Tool and command usage patterns. Group by stable tool name, command family," + + " invocation pattern, validation command, or repeated failure mode.", + null, + List.of("tool"), + DEFAULT_TARGET_TOKENS, + null, + null, + null, + InsightAnalysisMode.BRANCH, + null, + MemoryScope.AGENT); + } + // ── ROOT ───────────────────────────────────────────────────────────────── public static MemoryInsightType profile() { @@ -233,6 +250,7 @@ public static List all() { directives(), playbooks(), resolutions(), + tools(), profile(), interaction()); } diff --git a/memind-core/src/test/java/com/openmemind/ai/memory/core/data/DefaultInsightTypesTest.java b/memind-core/src/test/java/com/openmemind/ai/memory/core/data/DefaultInsightTypesTest.java index e7fa2734..daf4a12f 100644 --- a/memind-core/src/test/java/com/openmemind/ai/memory/core/data/DefaultInsightTypesTest.java +++ b/memind-core/src/test/java/com/openmemind/ai/memory/core/data/DefaultInsightTypesTest.java @@ -15,6 +15,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.openmemind.ai.memory.core.data.enums.InsightAnalysisMode; import com.openmemind.ai.memory.core.data.enums.MemoryScope; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -26,10 +27,16 @@ class DefaultInsightTypesTest { void allShouldExposeNewAgentBranchInsightTypes() { assertThat(DefaultInsightTypes.all()) .extracting(MemoryInsightType::name) - .contains("directives", "playbooks", "resolutions") + .contains("directives", "playbooks", "resolutions", "tools") .doesNotContain("proc" + "edural"); } + @Test + @DisplayName("all() should expose tools as an agent branch insight type") + void allShouldExposeToolsAgentBranchInsightType() { + assertThat(DefaultInsightTypes.all()).extracting(MemoryInsightType::name).contains("tools"); + } + @Test @DisplayName("agent branch insight types should map 1:1 to their categories") void agentBranchTypesShouldMapToTheirCategories() { @@ -38,6 +45,15 @@ void agentBranchTypesShouldMapToTheirCategories() { assertThat(DefaultInsightTypes.resolutions().categories()).containsExactly("resolution"); } + @Test + @DisplayName("tools should map to tool category and AGENT scope") + void toolsShouldMapToToolCategoryAndAgentScope() { + assertThat(DefaultInsightTypes.tools().categories()).containsExactly("tool"); + assertThat(DefaultInsightTypes.tools().scope()).isEqualTo(MemoryScope.AGENT); + assertThat(DefaultInsightTypes.tools().insightAnalysisMode()) + .isEqualTo(InsightAnalysisMode.BRANCH); + } + @Test @DisplayName("user branch insight types should remain user-scoped taxonomy definitions") void userBranchTypesShouldRemainUserScoped() { From 0a75c2335ab23a15676eaa73be3921005fff6cbd Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 25 May 2026 00:00:56 +0800 Subject: [PATCH 02/54] fix(core): reconcile default insight types on startup --- .../core/store/InMemoryMemoryStore.java | 4 +- .../insight/DefaultInsightTypeReconciler.java | 40 +++++++++++++++++ .../DefaultInsightTypeReconcilerTest.java | 45 +++++++++++++++++++ .../plugin/jdbc/mysql/MysqlMemoryStore.java | 10 ++--- .../postgresql/PostgresqlMemoryStore.java | 10 ++--- .../plugin/jdbc/sqlite/SqliteMemoryStore.java | 10 ++--- .../jdbc/sqlite/SqliteMemoryStoreTest.java | 21 +++++++++ 7 files changed, 117 insertions(+), 23 deletions(-) create mode 100644 memind-core/src/main/java/com/openmemind/ai/memory/core/store/insight/DefaultInsightTypeReconciler.java create mode 100644 memind-core/src/test/java/com/openmemind/ai/memory/core/store/insight/DefaultInsightTypeReconcilerTest.java diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/store/InMemoryMemoryStore.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/store/InMemoryMemoryStore.java index 63860412..5e585712 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/store/InMemoryMemoryStore.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/store/InMemoryMemoryStore.java @@ -13,12 +13,12 @@ */ package com.openmemind.ai.memory.core.store; -import com.openmemind.ai.memory.core.data.DefaultInsightTypes; import com.openmemind.ai.memory.core.store.graph.GraphOperations; import com.openmemind.ai.memory.core.store.graph.GraphOperationsCapabilities; import com.openmemind.ai.memory.core.store.graph.InMemoryGraphOperations; import com.openmemind.ai.memory.core.store.graph.InMemoryItemGraphCommitOperations; import com.openmemind.ai.memory.core.store.graph.ItemGraphCommitOperations; +import com.openmemind.ai.memory.core.store.insight.DefaultInsightTypeReconciler; import com.openmemind.ai.memory.core.store.insight.InMemoryInsightOperations; import com.openmemind.ai.memory.core.store.insight.InsightOperations; import com.openmemind.ai.memory.core.store.item.InMemoryItemOperations; @@ -56,7 +56,7 @@ public class InMemoryMemoryStore implements MemoryStore { private final ResourceOperations resourceOperations = new InMemoryResourceOperations(); public InMemoryMemoryStore() { - insightOperations.upsertInsightTypes(DefaultInsightTypes.all()); + DefaultInsightTypeReconciler.reconcile(insightOperations); } @Override diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/store/insight/DefaultInsightTypeReconciler.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/store/insight/DefaultInsightTypeReconciler.java new file mode 100644 index 00000000..72a2a8d4 --- /dev/null +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/store/insight/DefaultInsightTypeReconciler.java @@ -0,0 +1,40 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.core.store.insight; + +import com.openmemind.ai.memory.core.data.DefaultInsightTypes; +import com.openmemind.ai.memory.core.data.MemoryInsightType; +import java.util.List; + +/** + * Reconciles built-in insight type definitions into a store without overwriting local + * customizations. + */ +public final class DefaultInsightTypeReconciler { + + private DefaultInsightTypeReconciler() {} + + public static void reconcile(InsightOperations operations) { + if (operations == null) { + return; + } + List missing = + DefaultInsightTypes.all().stream() + .filter(type -> operations.getInsightType(type.name()).isEmpty()) + .toList(); + if (!missing.isEmpty()) { + operations.upsertInsightTypes(missing); + } + } +} diff --git a/memind-core/src/test/java/com/openmemind/ai/memory/core/store/insight/DefaultInsightTypeReconcilerTest.java b/memind-core/src/test/java/com/openmemind/ai/memory/core/store/insight/DefaultInsightTypeReconcilerTest.java new file mode 100644 index 00000000..6149ae60 --- /dev/null +++ b/memind-core/src/test/java/com/openmemind/ai/memory/core/store/insight/DefaultInsightTypeReconcilerTest.java @@ -0,0 +1,45 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.core.store.insight; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.openmemind.ai.memory.core.data.DefaultInsightTypes; +import java.util.List; +import org.junit.jupiter.api.Test; + +class DefaultInsightTypeReconcilerTest { + + @Test + void insertsMissingBuiltInTypesOnly() { + var ops = new InMemoryInsightOperations(); + ops.upsertInsightTypes(List.of(DefaultInsightTypes.identity())); + + DefaultInsightTypeReconciler.reconcile(ops); + + assertThat(ops.getInsightType("identity")).isPresent(); + assertThat(ops.getInsightType("tools")).isPresent(); + } + + @Test + void preservesExistingCustomizedType() { + var ops = new InMemoryInsightOperations(); + var customized = DefaultInsightTypes.tools().withTargetTokens(1234); + ops.upsertInsightTypes(List.of(customized)); + + DefaultInsightTypeReconciler.reconcile(ops); + + assertThat(ops.getInsightType("tools").orElseThrow().targetTokens()).isEqualTo(1234); + } +} diff --git a/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-mysql/src/main/java/com/openmemind/ai/memory/plugin/jdbc/mysql/MysqlMemoryStore.java b/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-mysql/src/main/java/com/openmemind/ai/memory/plugin/jdbc/mysql/MysqlMemoryStore.java index beea0029..12328d0b 100644 --- a/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-mysql/src/main/java/com/openmemind/ai/memory/plugin/jdbc/mysql/MysqlMemoryStore.java +++ b/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-mysql/src/main/java/com/openmemind/ai/memory/plugin/jdbc/mysql/MysqlMemoryStore.java @@ -13,7 +13,6 @@ */ package com.openmemind.ai.memory.plugin.jdbc.mysql; -import com.openmemind.ai.memory.core.data.DefaultInsightTypes; import com.openmemind.ai.memory.core.data.InsightPoint; import com.openmemind.ai.memory.core.data.MemoryId; import com.openmemind.ai.memory.core.data.MemoryInsight; @@ -37,6 +36,7 @@ import com.openmemind.ai.memory.core.store.graph.GraphOperations; import com.openmemind.ai.memory.core.store.graph.GraphOperationsCapabilities; import com.openmemind.ai.memory.core.store.graph.ItemGraphCommitOperations; +import com.openmemind.ai.memory.core.store.insight.DefaultInsightTypeReconciler; import com.openmemind.ai.memory.core.store.insight.InsightOperations; import com.openmemind.ai.memory.core.store.item.ItemOperations; import com.openmemind.ai.memory.core.store.item.ItemOperationsCapabilities; @@ -51,7 +51,6 @@ import com.openmemind.ai.memory.plugin.jdbc.internal.graph.JdbcGraphOperationsCapabilities; import com.openmemind.ai.memory.plugin.jdbc.internal.jdbi.JdbiFactory; import com.openmemind.ai.memory.plugin.jdbc.internal.schema.StoreSchemaBootstrap; -import com.openmemind.ai.memory.plugin.jdbc.internal.schema.StoreSchemaInitResult; import com.openmemind.ai.memory.plugin.jdbc.internal.support.JdbcPluginException; import com.openmemind.ai.memory.plugin.jdbc.internal.support.JsonCodec; import java.sql.Connection; @@ -123,11 +122,8 @@ public MysqlMemoryStore( this.jdbi = JdbiFactory.create(this.dataSource); this.jsonHelper = new JsonCodec(Objects.requireNonNull(objectMapper, "objectMapper")); this.resourceStore = resourceStore; - StoreSchemaInitResult initResult = - StoreSchemaBootstrap.ensureMysql(this.dataSource, createIfNotExist); - if (initResult.createdInsightTypeTable()) { - upsertInsightTypes(DefaultInsightTypes.all()); - } + StoreSchemaBootstrap.ensureMysql(this.dataSource, createIfNotExist); + DefaultInsightTypeReconciler.reconcile(this); this.graphOperations = new MysqlGraphOperations(this.dataSource, createIfNotExist); this.itemGraphCommitOperations = new MysqlItemGraphCommitOperations(this.dataSource); this.threadStore = new MysqlThreadStore(this.dataSource, createIfNotExist); diff --git a/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-postgresql/src/main/java/com/openmemind/ai/memory/plugin/jdbc/postgresql/PostgresqlMemoryStore.java b/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-postgresql/src/main/java/com/openmemind/ai/memory/plugin/jdbc/postgresql/PostgresqlMemoryStore.java index 0298ae25..e0f47e1f 100644 --- a/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-postgresql/src/main/java/com/openmemind/ai/memory/plugin/jdbc/postgresql/PostgresqlMemoryStore.java +++ b/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-postgresql/src/main/java/com/openmemind/ai/memory/plugin/jdbc/postgresql/PostgresqlMemoryStore.java @@ -13,7 +13,6 @@ */ package com.openmemind.ai.memory.plugin.jdbc.postgresql; -import com.openmemind.ai.memory.core.data.DefaultInsightTypes; import com.openmemind.ai.memory.core.data.InsightPoint; import com.openmemind.ai.memory.core.data.MemoryId; import com.openmemind.ai.memory.core.data.MemoryInsight; @@ -37,6 +36,7 @@ import com.openmemind.ai.memory.core.store.graph.GraphOperations; import com.openmemind.ai.memory.core.store.graph.GraphOperationsCapabilities; import com.openmemind.ai.memory.core.store.graph.ItemGraphCommitOperations; +import com.openmemind.ai.memory.core.store.insight.DefaultInsightTypeReconciler; import com.openmemind.ai.memory.core.store.insight.InsightOperations; import com.openmemind.ai.memory.core.store.item.ItemOperations; import com.openmemind.ai.memory.core.store.item.ItemOperationsCapabilities; @@ -51,7 +51,6 @@ import com.openmemind.ai.memory.plugin.jdbc.internal.graph.JdbcGraphOperationsCapabilities; import com.openmemind.ai.memory.plugin.jdbc.internal.jdbi.JdbiFactory; import com.openmemind.ai.memory.plugin.jdbc.internal.schema.StoreSchemaBootstrap; -import com.openmemind.ai.memory.plugin.jdbc.internal.schema.StoreSchemaInitResult; import com.openmemind.ai.memory.plugin.jdbc.internal.support.JdbcPluginException; import com.openmemind.ai.memory.plugin.jdbc.internal.support.JsonCodec; import java.sql.Connection; @@ -124,11 +123,8 @@ public PostgresqlMemoryStore( this.jdbi = JdbiFactory.create(this.dataSource); this.jsonHelper = new JsonCodec(Objects.requireNonNull(objectMapper, "objectMapper")); this.resourceStore = resourceStore; - StoreSchemaInitResult initResult = - StoreSchemaBootstrap.ensurePostgresql(this.dataSource, createIfNotExist); - if (initResult.createdInsightTypeTable()) { - upsertInsightTypes(DefaultInsightTypes.all()); - } + StoreSchemaBootstrap.ensurePostgresql(this.dataSource, createIfNotExist); + DefaultInsightTypeReconciler.reconcile(this); this.graphOperations = new PostgresqlGraphOperations(this.dataSource, createIfNotExist); this.itemGraphCommitOperations = new PostgresqlItemGraphCommitOperations(this.dataSource); this.threadStore = new PostgresqlThreadStore(this.dataSource, createIfNotExist); diff --git a/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-sqlite/src/main/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStore.java b/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-sqlite/src/main/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStore.java index 16a53efa..cda17c6b 100644 --- a/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-sqlite/src/main/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStore.java +++ b/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-sqlite/src/main/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStore.java @@ -13,7 +13,6 @@ */ package com.openmemind.ai.memory.plugin.jdbc.sqlite; -import com.openmemind.ai.memory.core.data.DefaultInsightTypes; import com.openmemind.ai.memory.core.data.InsightPoint; import com.openmemind.ai.memory.core.data.MemoryId; import com.openmemind.ai.memory.core.data.MemoryInsight; @@ -37,6 +36,7 @@ import com.openmemind.ai.memory.core.store.graph.GraphOperations; import com.openmemind.ai.memory.core.store.graph.GraphOperationsCapabilities; import com.openmemind.ai.memory.core.store.graph.ItemGraphCommitOperations; +import com.openmemind.ai.memory.core.store.insight.DefaultInsightTypeReconciler; import com.openmemind.ai.memory.core.store.insight.InsightOperations; import com.openmemind.ai.memory.core.store.item.ItemOperations; import com.openmemind.ai.memory.core.store.item.ItemOperationsCapabilities; @@ -51,7 +51,6 @@ import com.openmemind.ai.memory.plugin.jdbc.internal.graph.JdbcGraphOperationsCapabilities; import com.openmemind.ai.memory.plugin.jdbc.internal.jdbi.JdbiFactory; import com.openmemind.ai.memory.plugin.jdbc.internal.schema.StoreSchemaBootstrap; -import com.openmemind.ai.memory.plugin.jdbc.internal.schema.StoreSchemaInitResult; import com.openmemind.ai.memory.plugin.jdbc.internal.support.JdbcPluginException; import com.openmemind.ai.memory.plugin.jdbc.internal.support.JsonCodec; import java.sql.Connection; @@ -134,11 +133,8 @@ public SqliteMemoryStore( this.jdbi = JdbiFactory.create(this.dataSource); this.jsonHelper = new JsonCodec(Objects.requireNonNull(objectMapper, "objectMapper")); this.resourceStore = resourceStore; - StoreSchemaInitResult initResult = - StoreSchemaBootstrap.ensureSqlite(this.dataSource, createIfNotExist); - if (initResult.createdInsightTypeTable()) { - upsertInsightTypes(DefaultInsightTypes.all()); - } + StoreSchemaBootstrap.ensureSqlite(this.dataSource, createIfNotExist); + DefaultInsightTypeReconciler.reconcile(this); this.graphOperations = new SqliteGraphOperations(this.dataSource, createIfNotExist); this.itemGraphCommitOperations = new SqliteItemGraphCommitOperations(this.dataSource); this.threadStore = new SqliteThreadStore(this.dataSource, createIfNotExist); diff --git a/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-sqlite/src/test/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStoreTest.java b/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-sqlite/src/test/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStoreTest.java index 6ac0e2b6..f9d8931b 100644 --- a/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-sqlite/src/test/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStoreTest.java +++ b/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-sqlite/src/test/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStoreTest.java @@ -794,6 +794,27 @@ void insightTypesCanBeUpsertedAndListed() { DefaultInsightTypes.all().stream().map(MemoryInsightType::name).toList()); } + @Test + void constructorReconcilesMissingBuiltInInsightTypesForExistingStore() { + assertThat(store.getInsightType("tools")).isPresent(); + + executeUpdate("DELETE FROM memory_insight_type WHERE name = ?", "tools"); + + SqliteMemoryStore reopened = new SqliteMemoryStore(dataSource); + + assertThat(reopened.getInsightType("tools")).isPresent(); + } + + @Test + void constructorDoesNotOverwriteExistingCustomizedBuiltInInsightType() { + store.upsertInsightTypes(List.of(DefaultInsightTypes.tools().withTargetTokens(1234))); + + SqliteMemoryStore reopened = new SqliteMemoryStore(dataSource); + + assertThat(reopened.getInsightType("tools")).isPresent(); + assertThat(reopened.getInsightType("tools").orElseThrow().targetTokens()).isEqualTo(1234); + } + @Test void insightsCanBeQueriedByTreeSelectorsAndSoftDeleted() { MemoryInsight leaf = From d44d4dcf9be84323431d41c595aa640406a894dd Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 25 May 2026 00:05:19 +0800 Subject: [PATCH 03/54] refactor(core): share graph hint conversion --- .../strategy/LlmItemExtractionStrategy.java | 74 +------------ .../support/ExtractedGraphHintConverter.java | 102 ++++++++++++++++++ .../ExtractedGraphHintConverterTest.java | 75 +++++++++++++ 3 files changed, 179 insertions(+), 72 deletions(-) create mode 100644 memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedGraphHintConverter.java create mode 100644 memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedGraphHintConverterTest.java diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/strategy/LlmItemExtractionStrategy.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/strategy/LlmItemExtractionStrategy.java index 8bb0f885..ee6d8a0b 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/strategy/LlmItemExtractionStrategy.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/strategy/LlmItemExtractionStrategy.java @@ -17,9 +17,7 @@ import com.openmemind.ai.memory.core.data.enums.MemoryItemType; import com.openmemind.ai.memory.core.extraction.item.ItemExtractionConfig; import com.openmemind.ai.memory.core.extraction.item.ItemExtractionStrategy; -import com.openmemind.ai.memory.core.extraction.item.graph.EntityAliasClass; -import com.openmemind.ai.memory.core.extraction.item.graph.EntityAliasObservation; -import com.openmemind.ai.memory.core.extraction.item.support.ExtractedGraphHints; +import com.openmemind.ai.memory.core.extraction.item.support.ExtractedGraphHintConverter; import com.openmemind.ai.memory.core.extraction.item.support.ExtractedMemoryEntry; import com.openmemind.ai.memory.core.extraction.item.support.ExtractedTemporal; import com.openmemind.ai.memory.core.extraction.item.support.ForesightExtractionResponse; @@ -39,7 +37,6 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; @@ -300,8 +297,7 @@ private static ExtractedMemoryEntry toFactEntry( mergeMetadata(segment, item, temporal), MemoryItemType.FACT, item.category(), - new ExtractedGraphHints( - toEntityHints(item.entities()), toCausalHints(item.causalRelations()))); + ExtractedGraphHintConverter.from(item)); } private Mono> extractForesight( @@ -424,72 +420,6 @@ static float clamp(float value) { return Math.max(0.0f, Math.min(1.0f, value)); } - private static Float clampNullable(Float value) { - return value == null ? null : clamp(value); - } - - private static List toEntityHints( - List entities) { - if (entities == null || entities.isEmpty()) { - return List.of(); - } - return entities.stream() - .filter( - entity -> - entity != null && entity.name() != null && !entity.name().isBlank()) - .map( - entity -> - new ExtractedGraphHints.ExtractedEntityHint( - entity.name(), - entity.entityType(), - clampNullable(entity.salience()), - toAliasObservations(entity.aliasObservations()))) - .toList(); - } - - private static List toAliasObservations( - List observations) { - if (observations == null || observations.isEmpty()) { - return List.of(); - } - return observations.stream() - .filter(Objects::nonNull) - .map( - observation -> - EntityAliasClass.fromWireValue(observation.aliasClass()) - .map( - aliasClass -> - new EntityAliasObservation( - observation.aliasSurface(), - aliasClass, - observation.evidenceSource(), - clampNullable( - observation.confidence())))) - .flatMap(Optional::stream) - .toList(); - } - - private static List toCausalHints( - List causalRelations) { - if (causalRelations == null || causalRelations.isEmpty()) { - return List.of(); - } - return causalRelations.stream() - .filter( - relation -> - relation != null - && relation.causeIndex() != null - && relation.effectIndex() != null) - .map( - relation -> - new ExtractedGraphHints.ExtractedCausalRelationHint( - relation.causeIndex(), - relation.effectIndex(), - relation.relationType(), - clampNullable(relation.strength()))) - .toList(); - } - static Instant resolveReferenceTime(ParsedSegment segment) { var observedAt = resolveObservedAt(segment); return observedAt != null ? observedAt : Instant.now(); diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedGraphHintConverter.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedGraphHintConverter.java new file mode 100644 index 00000000..a9524f0d --- /dev/null +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedGraphHintConverter.java @@ -0,0 +1,102 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.core.extraction.item.support; + +import com.openmemind.ai.memory.core.extraction.item.graph.EntityAliasClass; +import com.openmemind.ai.memory.core.extraction.item.graph.EntityAliasObservation; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Converts structured item extraction graph fields into core graph hints. + */ +public final class ExtractedGraphHintConverter { + + private ExtractedGraphHintConverter() {} + + public static ExtractedGraphHints from(MemoryItemExtractionResponse.ExtractedItem item) { + if (item == null) { + return ExtractedGraphHints.empty(); + } + return new ExtractedGraphHints( + toEntityHints(item.entities()), toCausalHints(item.causalRelations())); + } + + public static List toEntityHints( + List entities) { + if (entities == null || entities.isEmpty()) { + return List.of(); + } + return entities.stream() + .filter( + entity -> + entity != null && entity.name() != null && !entity.name().isBlank()) + .map( + entity -> + new ExtractedGraphHints.ExtractedEntityHint( + entity.name(), + entity.entityType(), + clampNullable(entity.salience()), + toAliasObservations(entity.aliasObservations()))) + .toList(); + } + + public static List toCausalHints( + List causalRelations) { + if (causalRelations == null || causalRelations.isEmpty()) { + return List.of(); + } + return causalRelations.stream() + .filter( + relation -> + relation != null + && relation.causeIndex() != null + && relation.effectIndex() != null) + .map( + relation -> + new ExtractedGraphHints.ExtractedCausalRelationHint( + relation.causeIndex(), + relation.effectIndex(), + relation.relationType(), + clampNullable(relation.strength()))) + .toList(); + } + + private static List toAliasObservations( + List observations) { + if (observations == null || observations.isEmpty()) { + return List.of(); + } + return observations.stream() + .filter(Objects::nonNull) + .map( + observation -> + EntityAliasClass.fromWireValue(observation.aliasClass()) + .map( + aliasClass -> + new EntityAliasObservation( + observation.aliasSurface(), + aliasClass, + observation.evidenceSource(), + clampNullable( + observation.confidence())))) + .flatMap(Optional::stream) + .toList(); + } + + private static Float clampNullable(Float value) { + return value == null ? null : Math.max(0.0f, Math.min(1.0f, value)); + } +} diff --git a/memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedGraphHintConverterTest.java b/memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedGraphHintConverterTest.java new file mode 100644 index 00000000..c2177248 --- /dev/null +++ b/memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedGraphHintConverterTest.java @@ -0,0 +1,75 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.core.extraction.item.support; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ExtractedGraphHintConverterTest { + + @Test + void convertsEntitiesAndCausalRelations() { + var item = + new MemoryItemExtractionResponse.ExtractedItem( + "content", + 0.9f, + null, + null, + List.of("resolutions"), + Map.of(), + "resolution", + List.of( + new MemoryItemExtractionResponse.ExtractedEntity( + "src/payment/calc.ts", "object", 1.5f)), + List.of( + new MemoryItemExtractionResponse.ExtractedCausalRelation( + 0, 1, "enabled_by", -1.0f))); + + ExtractedGraphHints hints = ExtractedGraphHintConverter.from(item); + + assertThat(hints.entities()).hasSize(1); + assertThat(hints.entities().getFirst().name()).isEqualTo("src/payment/calc.ts"); + assertThat(hints.entities().getFirst().salience()).isEqualTo(1.0f); + assertThat(hints.causalRelations()).hasSize(1); + assertThat(hints.causalRelations().getFirst().relationType()).isEqualTo("enabled_by"); + assertThat(hints.causalRelations().getFirst().strength()).isEqualTo(0.0f); + } + + @Test + void dropsBlankEntitiesAndIncompleteCausalRelations() { + var item = + new MemoryItemExtractionResponse.ExtractedItem( + "content", + 0.9f, + null, + null, + List.of(), + Map.of(), + "tool", + List.of( + new MemoryItemExtractionResponse.ExtractedEntity( + " ", "object", 0.5f)), + List.of( + new MemoryItemExtractionResponse.ExtractedCausalRelation( + null, 1, "enabled_by", 0.5f))); + + ExtractedGraphHints hints = ExtractedGraphHintConverter.from(item); + + assertThat(hints.entities()).isEmpty(); + assertThat(hints.causalRelations()).isEmpty(); + } +} From 60d2b87d4ba9cbd05d844d40c1c7ff84c4c7c043 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 25 May 2026 10:13:00 +0800 Subject: [PATCH 04/54] feat(retrieval): expose item category metadata --- .../response/RetrieveMemoryResponse.java | 19 ++++- .../python/src/memind/types/memory.py | 2 + .../typescript/src/core/validate.ts | 5 ++ memind-clients/typescript/src/types/memory.ts | 2 + .../memory/core/llm/rerank/LlmReranker.java | 16 +--- .../graph/DefaultRetrievalGraphAssistant.java | 8 +- .../retrieval/graph/GraphExpansionEngine.java | 13 +-- .../retrieval/scoring/RawDataAggregator.java | 49 ++++++------ .../core/retrieval/scoring/ResultMerger.java | 48 ++--------- .../core/retrieval/scoring/ScoredResult.java | 79 ++++++++++++++++++- .../core/retrieval/scoring/TimeDecay.java | 9 +-- .../temporal/DefaultTemporalItemChannel.java | 9 +-- .../thread/ThreadAssistMemberRanker.java | 5 +- .../retrieval/tier/ItemTierRetriever.java | 26 +++--- .../response/RetrieveMemoryResponse.java | 19 ++++- .../memory/OpenMemoryApplicationService.java | 4 +- .../OpenMemoryApplicationServiceTest.java | 9 ++- 17 files changed, 191 insertions(+), 131 deletions(-) diff --git a/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/response/RetrieveMemoryResponse.java b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/response/RetrieveMemoryResponse.java index 1329d536..5dc93602 100644 --- a/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/response/RetrieveMemoryResponse.java +++ b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/response/RetrieveMemoryResponse.java @@ -16,6 +16,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import java.time.Instant; import java.util.List; +import java.util.Map; @JsonIgnoreProperties(ignoreUnknown = true) public record RetrieveMemoryResponse( @@ -30,7 +31,23 @@ public record RetrieveMemoryResponse( @JsonIgnoreProperties(ignoreUnknown = true) public record RetrievedItem( - String id, String text, float vectorScore, double finalScore, Instant occurredAt) {} + String id, + String text, + float vectorScore, + double finalScore, + Instant occurredAt, + String category, + Map metadata) { + + public RetrievedItem( + String id, String text, float vectorScore, double finalScore, Instant occurredAt) { + this(id, text, vectorScore, finalScore, occurredAt, null, Map.of()); + } + + public RetrievedItem { + metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + } + } @JsonIgnoreProperties(ignoreUnknown = true) public record RetrievedInsight(String id, String text, String tier) {} diff --git a/memind-clients/python/src/memind/types/memory.py b/memind-clients/python/src/memind/types/memory.py index b26c5bf8..6eb1c1aa 100644 --- a/memind-clients/python/src/memind/types/memory.py +++ b/memind-clients/python/src/memind/types/memory.py @@ -72,6 +72,8 @@ class RetrievedItem(MemindModel): vector_score: float = 0.0 final_score: float = 0.0 occurred_at: str | None = None + category: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) class RetrievedInsight(MemindModel): diff --git a/memind-clients/typescript/src/core/validate.ts b/memind-clients/typescript/src/core/validate.ts index 48adf744..ff238f6a 100644 --- a/memind-clients/typescript/src/core/validate.ts +++ b/memind-clients/typescript/src/core/validate.ts @@ -168,6 +168,11 @@ function assertRetrievedItem(value: unknown, index: number): RetrievedItem { } const occurredAt = optionalString(obj.occurredAt, `items[${index}].occurredAt`) if (occurredAt !== undefined) item.occurredAt = occurredAt + const category = optionalString(obj.category, `items[${index}].category`) + if (category !== undefined) item.category = category + if (obj.metadata !== undefined && obj.metadata !== null) { + item.metadata = objectRecord(obj.metadata, `items[${index}].metadata`) + } return item } diff --git a/memind-clients/typescript/src/types/memory.ts b/memind-clients/typescript/src/types/memory.ts index 642e5279..e14f1d61 100644 --- a/memind-clients/typescript/src/types/memory.ts +++ b/memind-clients/typescript/src/types/memory.ts @@ -64,6 +64,8 @@ export type RetrievedItem = { vectorScore: number finalScore: number occurredAt?: string + category?: string + metadata?: Record } export type RetrievedInsight = { diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/llm/rerank/LlmReranker.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/llm/rerank/LlmReranker.java index e7edb31e..1b77851c 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/llm/rerank/LlmReranker.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/llm/rerank/LlmReranker.java @@ -128,13 +128,7 @@ private List applyScores(List results, RerankApiResp var original = results.get(r.index); int retrievalRank = r.index + 1; double blended = legacyBlendScore(retrievalRank, r.relevanceScore); - return new ScoredResult( - original.sourceType(), - original.sourceId(), - original.text(), - original.vectorScore(), - blended, - original.occurredAt()); + return original.withFinalScore(blended); }) .sorted(Comparator.comparingDouble(ScoredResult::finalScore).reversed()) .toList(); @@ -272,13 +266,7 @@ private List applyScores( // Pure mode: reranker score is the final score finalScore = r.relevanceScore; } - return new ScoredResult( - original.sourceType(), - original.sourceId(), - original.text(), - original.vectorScore(), - finalScore, - original.occurredAt()); + return original.withFinalScore(finalScore); }) .sorted(Comparator.comparingDouble(ScoredResult::finalScore).reversed()) .toList(); diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/graph/DefaultRetrievalGraphAssistant.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/graph/DefaultRetrievalGraphAssistant.java index 486c375b..76c2b226 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/graph/DefaultRetrievalGraphAssistant.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/graph/DefaultRetrievalGraphAssistant.java @@ -218,13 +218,7 @@ private long requireItemId(String sourceId) { } private ScoredResult rescore(ScoredResult result, double fusedScore) { - return new ScoredResult( - result.sourceType(), - result.sourceId(), - result.text(), - result.vectorScore(), - fusedScore, - result.occurredAt()); + return result.withFinalScore(fusedScore); } private int countDisplacedDirectItems( diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/graph/GraphExpansionEngine.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/graph/GraphExpansionEngine.java index 4ff45f92..48ac4299 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/graph/GraphExpansionEngine.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/graph/GraphExpansionEngine.java @@ -386,9 +386,8 @@ private List rankGraphCandidates(Map candida return sorted.stream() .map( candidate -> - new ScoredResult( - ScoredResult.SourceType.ITEM, - String.valueOf(candidate.itemId()), + ScoredResult.fromItem( + candidate.item(), candidate.content() == null ? "" : candidate.content(), 0f, candidate.score() / divisor, @@ -497,7 +496,7 @@ private GraphCandidate resolve( * recencyAdjustment; double nonSemanticScore = bestNonSemanticBaseScore * recencyAdjustment; return new GraphCandidate( - itemId, + item, Math.max(semanticScore, nonSemanticScore), item.content(), item.occurredAt()); @@ -522,7 +521,11 @@ private double accumulateSemantic(double decayFactor) { } private record GraphCandidate( - long itemId, double score, String content, java.time.Instant occurredAt) {} + MemoryItem item, double score, String content, java.time.Instant occurredAt) { + private long itemId() { + return item.id(); + } + } private enum RelationFamily { SEMANTIC, diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/RawDataAggregator.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/RawDataAggregator.java index 96341ae9..e01115c6 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/RawDataAggregator.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/RawDataAggregator.java @@ -237,14 +237,7 @@ record Parsed(ScoredResult result, Long itemId) {} String caption = rawData.map(MemoryRawData::caption).orElse(null); String text = (caption != null && !caption.isBlank()) ? caption : best.text(); - aggregated.add( - new ScoredResult( - best.sourceType(), - best.sourceId(), - text, - best.vectorScore(), - best.finalScore(), - best.occurredAt())); + aggregated.add(best.withTextAndScores(text, best.vectorScore(), best.finalScore())); } } @@ -254,10 +247,11 @@ record Parsed(ScoredResult result, Long itemId) {} } /** - * Batch fill timestamps for ITEM type results with occurredAt as null. + * Batch fill item attributes for ITEM type results with missing occurredAt, category, or + * metadata. * - *

ScoredResult created by BM25 channel does not carry occurredAt, this method fills it by - * batch querying MemoryStore. + *

ScoredResult created by BM25 channel does not carry MemoryItem fields, this method fills + * them by batch querying MemoryStore. * * @param results List of results to be filled * @param memoryId Memory identifier @@ -270,13 +264,14 @@ public static List backfillOccurredAt( return results; } - // Collect ITEM sourceIds that need to be filled List missingIds = results.stream() .filter( r -> r.sourceType() == ScoredResult.SourceType.ITEM - && r.occurredAt() == null) + && (r.occurredAt() == null + || r.category() == null + || r.metadata().isEmpty())) .map( r -> { try { @@ -292,14 +287,11 @@ public static List backfillOccurredAt( return results; } - Map idToOccurredAt = + Map itemsById = store.itemOperations().getItemsByIds(memoryId, missingIds).stream() - .filter(mi -> mi.occurredAt() != null) - .collect( - Collectors.toMap( - MemoryItem::id, MemoryItem::occurredAt, (a, b) -> a)); + .collect(Collectors.toMap(MemoryItem::id, item -> item, (a, b) -> a)); - if (idToOccurredAt.isEmpty()) { + if (itemsById.isEmpty()) { return results; } @@ -307,11 +299,22 @@ public static List backfillOccurredAt( .map( r -> { if (r.sourceType() == ScoredResult.SourceType.ITEM - && r.occurredAt() == null) { + && (r.occurredAt() == null + || r.category() == null + || r.metadata().isEmpty())) { try { - Instant ts = idToOccurredAt.get(Long.parseLong(r.sourceId())); - if (ts != null) { - return r.withOccurredAt(ts); + MemoryItem item = itemsById.get(Long.parseLong(r.sourceId())); + if (item != null) { + Instant occurredAt = + r.occurredAt() != null + ? r.occurredAt() + : item.occurredAt(); + return ScoredResult.fromItem( + item, + r.text(), + r.vectorScore(), + r.finalScore(), + occurredAt); } } catch (NumberFormatException ignored) { } diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/ResultMerger.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/ResultMerger.java index 5e41f93a..ea8cb239 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/ResultMerger.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/ResultMerger.java @@ -115,14 +115,7 @@ public static List merge( .forEach( entry -> { ScoredResult best = bestResults.get(entry.getKey()); - merged.add( - new ScoredResult( - best.sourceType(), - best.sourceId(), - best.text(), - best.vectorScore(), - entry.getValue(), - best.occurredAt())); + merged.add(best.withFinalScore(entry.getValue())); }); // Normalize RRF scores to [0, 1], so minScore filtering works properly @@ -135,14 +128,7 @@ private static List normalize(List results) { if (results.size() <= 1) { if (results.size() == 1) { ScoredResult r = results.get(0); - return List.of( - new ScoredResult( - r.sourceType(), - r.sourceId(), - r.text(), - r.vectorScore(), - 1.0, - r.occurredAt())); + return List.of(r.withFinalScore(1.0)); } return List.copyOf(results); } @@ -152,17 +138,7 @@ private static List normalize(List results) { return List.copyOf(results); } - return results.stream() - .map( - r -> - new ScoredResult( - r.sourceType(), - r.sourceId(), - r.text(), - r.vectorScore(), - r.finalScore() / maxScore, - r.occurredAt())) - .toList(); + return results.stream().map(r -> r.withFinalScore(r.finalScore() / maxScore)).toList(); } /** Top-rank bonus: rank 1 +0.05, rank 2-3 +0.02 (reference QMD) */ @@ -180,14 +156,7 @@ private static List applyTopRankBonus( } else if (i <= 2) { bonus = scoring.positionBonus().top3(); } - boosted.add( - new ScoredResult( - r.sourceType(), - r.sourceId(), - r.text(), - r.vectorScore(), - r.finalScore() + bonus, - r.occurredAt())); + boosted.add(r.withFinalScore(r.finalScore() + bonus)); } return boosted; } @@ -256,14 +225,7 @@ public static List mergeByRelativeScore(List> r .forEach( entry -> { ScoredResult best = bestResults.get(entry.getKey()); - merged.add( - new ScoredResult( - best.sourceType(), - best.sourceId(), - best.text(), - best.vectorScore(), - entry.getValue(), - best.occurredAt())); + merged.add(best.withFinalScore(entry.getValue())); }); return normalize(merged); diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/ScoredResult.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/ScoredResult.java index 515e7a2b..2cd536f2 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/ScoredResult.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/ScoredResult.java @@ -14,7 +14,9 @@ package com.openmemind.ai.memory.core.retrieval.scoring; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.openmemind.ai.memory.core.data.MemoryItem; import java.time.Instant; +import java.util.Map; /** * Unified scoring result @@ -28,6 +30,8 @@ * @param vectorScore Raw vector similarity score (0.0-1.0) * @param finalScore Final score (calculated by ScoringStrategy) * @param occurredAt Memory occurrence time (only ITEM type may have a value, Profile/Behavior class is null) + * @param category Memory item category name (only ITEM type may have a value) + * @param metadata Memory item metadata (only ITEM type may have values) */ public record ScoredResult( SourceType sourceType, @@ -35,7 +39,13 @@ public record ScoredResult( String text, float vectorScore, double finalScore, - Instant occurredAt) { + Instant occurredAt, + String category, + Map metadata) { + + public ScoredResult { + metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + } /** 5 parameter compatible constructor (occurredAt defaults to null) */ public ScoredResult( @@ -44,7 +54,18 @@ public ScoredResult( String text, float vectorScore, double finalScore) { - this(sourceType, sourceId, text, vectorScore, finalScore, null); + this(sourceType, sourceId, text, vectorScore, finalScore, null, null, Map.of()); + } + + /** 6 parameter compatible constructor (category/metadata default to empty) */ + public ScoredResult( + SourceType sourceType, + String sourceId, + String text, + float vectorScore, + double finalScore, + Instant occurredAt) { + this(sourceType, sourceId, text, vectorScore, finalScore, occurredAt, null, Map.of()); } /** Source type */ @@ -62,6 +83,58 @@ public String dedupKey() { /** Returns a copy with the specified occurredAt */ public ScoredResult withOccurredAt(Instant occurredAt) { - return new ScoredResult(sourceType, sourceId, text, vectorScore, finalScore, occurredAt); + return new ScoredResult( + sourceType, + sourceId, + text, + vectorScore, + finalScore, + occurredAt, + category, + metadata); + } + + /** Returns a copy with the specified final score. */ + public ScoredResult withFinalScore(double finalScore) { + return new ScoredResult( + sourceType, + sourceId, + text, + vectorScore, + finalScore, + occurredAt, + category, + metadata); + } + + /** Returns a copy with the specified text and scoring fields. */ + public ScoredResult withTextAndScores(String text, float vectorScore, double finalScore) { + return new ScoredResult( + sourceType, + sourceId, + text, + vectorScore, + finalScore, + occurredAt, + category, + metadata); + } + + /** Builds an ITEM retrieval result from a MemoryItem while preserving item category and metadata. */ + public static ScoredResult fromItem( + MemoryItem item, + String text, + float vectorScore, + double finalScore, + Instant occurredAt) { + return new ScoredResult( + SourceType.ITEM, + String.valueOf(item.id()), + text, + vectorScore, + finalScore, + occurredAt, + item.category() == null ? null : item.category().categoryName(), + item.metadata()); } } diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/TimeDecay.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/TimeDecay.java index 44fcca38..b6a4c1a1 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/TimeDecay.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/TimeDecay.java @@ -100,14 +100,7 @@ public static List applyToBm25Only( double decayFactor = factor(r.occurredAt(), context, scoring); if (decayFactor < 1.0) { anyChanged = true; - updated.add( - new ScoredResult( - r.sourceType(), - r.sourceId(), - r.text(), - r.vectorScore(), - r.finalScore() * decayFactor, - r.occurredAt())); + updated.add(r.withFinalScore(r.finalScore() * decayFactor)); continue; } } diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/temporal/DefaultTemporalItemChannel.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/temporal/DefaultTemporalItemChannel.java index 4a7b6c20..5f16c154 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/temporal/DefaultTemporalItemChannel.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/temporal/DefaultTemporalItemChannel.java @@ -111,13 +111,8 @@ private TemporalItemChannelResult retrieveBlocking( private ScoredResult toScoredResult( TemporalConstraint constraint, TemporalItemLookupMatch match) { MemoryItem item = match.item(); - return new ScoredResult( - ScoredResult.SourceType.ITEM, - String.valueOf(item.id()), - item.content(), - 0f, - temporalProximity(constraint, match), - match.anchor()); + return ScoredResult.fromItem( + item, item.content(), 0f, temporalProximity(constraint, match), match.anchor()); } private double temporalProximity(TemporalConstraint constraint, TemporalItemLookupMatch match) { diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/thread/ThreadAssistMemberRanker.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/thread/ThreadAssistMemberRanker.java index 76c37791..bd75aa1a 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/thread/ThreadAssistMemberRanker.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/thread/ThreadAssistMemberRanker.java @@ -124,9 +124,8 @@ List admit( .limit(effectivePerThreadCap) .map( candidate -> - new ScoredResult( - ScoredResult.SourceType.ITEM, - Long.toString(candidate.item().id()), + ScoredResult.fromItem( + candidate.item(), candidate.item().content(), 0.0f, candidate.membership().relevanceWeight(), diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/tier/ItemTierRetriever.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/tier/ItemTierRetriever.java index 3fb9c024..cb75bb79 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/tier/ItemTierRetriever.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/tier/ItemTierRetriever.java @@ -143,13 +143,12 @@ public Mono searchByVector(QueryContext context, RetrievalConfig con } scoredResults.add( - new ScoredResult( - ScoredResult.SourceType.ITEM, - String.valueOf(item.id()), - formatItemText(item), - vr.score(), - finalScore) - .withOccurredAt(item.occurredAt())); + ScoredResult.fromItem( + item, + formatItemText(item), + vr.score(), + finalScore, + item.occurredAt())); if (item.rawDataId() != null) { rawDataIds.add(item.rawDataId()); @@ -249,13 +248,12 @@ public Mono retrieve( } scoredResults.add( - new ScoredResult( - ScoredResult.SourceType.ITEM, - String.valueOf(item.id()), - formatItemText(item), - vr.score(), - finalScore) - .withOccurredAt(item.occurredAt())); + ScoredResult.fromItem( + item, + formatItemText(item), + vr.score(), + finalScore, + item.occurredAt())); if (item.rawDataId() != null) { rawDataIds.add(item.rawDataId()); } diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/response/RetrieveMemoryResponse.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/response/RetrieveMemoryResponse.java index fe0bc026..dc496a88 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/response/RetrieveMemoryResponse.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/response/RetrieveMemoryResponse.java @@ -15,6 +15,7 @@ import java.time.Instant; import java.util.List; +import java.util.Map; public record RetrieveMemoryResponse( String status, @@ -38,7 +39,23 @@ public RetrieveMemoryResponse( } public record RetrievedItemView( - String id, String text, float vectorScore, double finalScore, Instant occurredAt) {} + String id, + String text, + float vectorScore, + double finalScore, + Instant occurredAt, + String category, + Map metadata) { + + public RetrievedItemView( + String id, String text, float vectorScore, double finalScore, Instant occurredAt) { + this(id, text, vectorScore, finalScore, occurredAt, null, Map.of()); + } + + public RetrievedItemView { + metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + } + } public record RetrievedInsightView(String id, String text, String tier) {} diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryApplicationService.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryApplicationService.java index 213abaef..ec281148 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryApplicationService.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryApplicationService.java @@ -336,6 +336,8 @@ private static RetrieveMemoryResponse.RetrievedItemView toRetrievedItemView(Scor item.text(), item.vectorScore(), item.finalScore(), - item.occurredAt()); + item.occurredAt(), + item.category(), + item.metadata()); } } diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryApplicationServiceTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryApplicationServiceTest.java index 43050e78..e6b7923a 100644 --- a/memind-server/src/test/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryApplicationServiceTest.java +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryApplicationServiceTest.java @@ -164,7 +164,9 @@ void retrieveMapsRetrievalResult() { "loves coffee", 0.82F, 0.91, - Instant.parse("2026-03-30T10:00:00Z"))), + Instant.parse("2026-03-30T10:00:00Z"), + "tool", + Map.of("toolName", "Bash"))), List.of( new RetrievalResult.InsightResult( "insight-1", "prefers concise answers", InsightTier.LEAF)), @@ -185,6 +187,11 @@ void retrieveMapsRetrievalResult() { "u1", "a1", "coffee", RetrievalConfig.Strategy.SIMPLE)); assertThat(response.items()).singleElement().extracting("id").isEqualTo("item-1"); + assertThat(response.items()).singleElement().extracting("category").isEqualTo("tool"); + assertThat(response.items()) + .singleElement() + .extracting("metadata") + .isEqualTo(Map.of("toolName", "Bash")); assertThat(response.insights()).singleElement().extracting("tier").isEqualTo("LEAF"); assertThat(response.rawData()).singleElement().extracting("rawDataId").isEqualTo("rd-1"); assertThat(runtimeManager.currentHandle().inFlightRequests()).hasValue(0); From 9b6ec319f4de0bb3c5e77faf263c3105b445fc09 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 25 May 2026 10:21:13 +0800 Subject: [PATCH 05/54] feat(rawdata-agent): scaffold agent timeline content --- .../memind-plugin-rawdata-agent/pom.xml | 58 ++++ .../agent/AgentRawContentTypeRegistrar.java | 30 ++ .../agent/content/AgentTimelineContent.java | 279 ++++++++++++++++++ .../rawdata/agent/model/AgentEvent.java | 42 +++ .../rawdata/agent/model/AgentEventKind.java | 50 ++++ .../rawdata/agent/model/AgentEventStatus.java | 42 +++ .../rawdata/agent/model/AgentGitContext.java | 19 ++ .../rawdata/agent/model/AgentProject.java | 37 +++ .../AgentRawContentTypeRegistrarTest.java | 28 ++ .../content/AgentTimelineContentTest.java | 130 ++++++++ memind-plugins/memind-plugin-rawdatas/pom.xml | 1 + 11 files changed, 716 insertions(+) create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/pom.xml create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/AgentRawContentTypeRegistrar.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEvent.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEventKind.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEventStatus.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentGitContext.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentProject.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/AgentRawContentTypeRegistrarTest.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/pom.xml b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/pom.xml new file mode 100644 index 00000000..19857a9b --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/pom.xml @@ -0,0 +1,58 @@ + + + + 4.0.0 + + com.openmemind.ai + memind-plugin-rawdatas + ${revision} + ../pom.xml + + + memind-plugin-rawdata-agent + Memind - Agent RawData Plugin + + + + com.openmemind.ai + memind-core + ${revision} + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + org.mockito + mockito-junit-jupiter + test + + + io.projectreactor + reactor-test + test + + + diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/AgentRawContentTypeRegistrar.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/AgentRawContentTypeRegistrar.java new file mode 100644 index 00000000..279fe6c1 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/AgentRawContentTypeRegistrar.java @@ -0,0 +1,30 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent; + +import com.openmemind.ai.memory.core.extraction.rawdata.RawContentTypeRegistrar; +import com.openmemind.ai.memory.core.extraction.rawdata.content.RawContent; +import com.openmemind.ai.memory.plugin.rawdata.agent.content.AgentTimelineContent; +import java.util.Map; + +/** + * Plugin-owned agent timeline raw content type registrar. + */ +public final class AgentRawContentTypeRegistrar implements RawContentTypeRegistrar { + + @Override + public Map> subtypes() { + return Map.of("agent_timeline", AgentTimelineContent.class); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java new file mode 100644 index 00000000..9d424ded --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java @@ -0,0 +1,279 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.content; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.openmemind.ai.memory.core.extraction.rawdata.content.RawContent; +import com.openmemind.ai.memory.core.utils.HashUtils; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEvent; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventKind; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentProject; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.stream.Collectors; + +/** + * Raw content for a coding-agent timeline. + */ +public final class AgentTimelineContent extends RawContent { + + public static final String TYPE = "AGENT_TIMELINE"; + + private static final int MAX_EVENT_FIELD_CHARS = 500; + + private final String sourceClient; + private final String sourceVersion; + private final String sessionId; + private final String timelineId; + private final AgentProject project; + private final List events; + private final Map metadata; + + public AgentTimelineContent( + String sourceClient, + String sourceVersion, + String sessionId, + String timelineId, + AgentProject project, + List events) { + this(sourceClient, sourceVersion, sessionId, timelineId, project, events, Map.of()); + } + + @JsonCreator + public AgentTimelineContent( + @JsonProperty("sourceClient") String sourceClient, + @JsonProperty("sourceVersion") String sourceVersion, + @JsonProperty("sessionId") String sessionId, + @JsonProperty("timelineId") String timelineId, + @JsonProperty("project") AgentProject project, + @JsonProperty("events") List events, + @JsonProperty("metadata") Map metadata) { + this.sourceClient = sourceClient; + this.sourceVersion = sourceVersion; + this.sessionId = sessionId; + this.timelineId = timelineId; + this.project = project; + this.events = sortedEvents(events); + this.metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + } + + @Override + public String contentType() { + return TYPE; + } + + @Override + public String toContentString() { + var lines = new ArrayList(); + lines.add("Agent Timeline"); + append(lines, "Source", sourceClientWithVersion()); + append(lines, "Session", sessionId); + append(lines, "Timeline", timelineId); + if (project != null) { + append(lines, "Project", project.toDisplayString()); + } + firstGoal().ifPresent(goal -> lines.add("Goal: " + goal)); + if (!events.isEmpty()) { + lines.add("Events:"); + events.forEach(event -> lines.add("- " + formatEvent(event))); + } + return String.join("\n", lines); + } + + @Override + public String getContentId() { + String eventIds = events.stream().map(AgentEvent::id).collect(Collectors.joining(",")); + String eventHash = + HashUtils.sampledSha256( + events.stream().map(this::canonicalEvent).collect(Collectors.joining("|"))); + return HashUtils.sampledSha256( + String.join( + "|", + normalized(sourceClient), + normalized(sessionId), + normalized(timelineId), + eventIds, + normalized(eventHash))); + } + + @Override + public Map contentMetadata() { + return metadata; + } + + @Override + public RawContent withMetadata(Map metadata) { + return new AgentTimelineContent( + sourceClient, sourceVersion, sessionId, timelineId, project, events, metadata); + } + + @JsonProperty("sourceClient") + public String sourceClient() { + return sourceClient; + } + + @JsonProperty("sourceVersion") + public String sourceVersion() { + return sourceVersion; + } + + @JsonProperty("sessionId") + public String sessionId() { + return sessionId; + } + + @JsonProperty("timelineId") + public String timelineId() { + return timelineId; + } + + @JsonProperty("project") + public AgentProject project() { + return project; + } + + @JsonProperty("events") + public List events() { + return events; + } + + @JsonProperty("metadata") + public Map metadata() { + return metadata; + } + + private java.util.Optional firstGoal() { + return events.stream() + .filter(event -> event.kind() == AgentEventKind.USER_PROMPT) + .map(AgentEvent::text) + .filter(text -> text != null && !text.isBlank()) + .findFirst(); + } + + private String sourceClientWithVersion() { + if (sourceVersion == null || sourceVersion.isBlank()) { + return sourceClient; + } + if (sourceClient == null || sourceClient.isBlank()) { + return sourceVersion; + } + return sourceClient + " " + sourceVersion; + } + + private static List sortedEvents(List events) { + if (events == null || events.isEmpty()) { + return List.of(); + } + return events.stream() + .filter(Objects::nonNull) + .sorted( + Comparator.comparing( + AgentEvent::seq, Comparator.nullsLast(Integer::compareTo)) + .thenComparing( + AgentEvent::occurredAt, + Comparator.nullsLast(Instant::compareTo)) + .thenComparing( + AgentEvent::id, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private static void append(List lines, String label, String value) { + if (value != null && !value.isBlank()) { + lines.add(label + ": " + value); + } + } + + private static String formatEvent(AgentEvent event) { + var parts = new ArrayList(); + if (event.seq() != null) { + parts.add("#" + event.seq()); + } + if (event.kind() != null) { + parts.add(event.kind().wireValue()); + } + if (event.id() != null && !event.id().isBlank()) { + parts.add(event.id()); + } + if (event.status() != null) { + parts.add("[" + event.status().wireValue() + "]"); + } + appendPart(parts, "tool", event.toolName()); + appendPart(parts, "command", event.command()); + appendPart(parts, "path", event.path()); + appendPart(parts, "operation", event.operation()); + appendText(parts, event.text()); + appendText(parts, event.output()); + return String.join(" ", parts); + } + + private static void appendPart(List parts, String label, String value) { + if (value != null && !value.isBlank()) { + parts.add(label + "=" + clamp(value)); + } + } + + private static void appendText(List parts, String value) { + if (value != null && !value.isBlank()) { + parts.add(clamp(value)); + } + } + + private String canonicalEvent(AgentEvent event) { + return String.join( + "\u001f", + normalized(event.id()), + normalized(event.seq()), + event.kind() == null ? "" : event.kind().wireValue(), + normalized(event.occurredAt()), + normalized(event.text()), + normalized(event.toolName()), + normalized(event.input()), + normalized(event.output()), + event.status() == null ? "" : event.status().wireValue(), + normalized(event.durationMs()), + normalized(event.path()), + normalized(event.operation()), + normalized(event.command()), + normalized(event.exitCode()), + canonicalMap(event.metadata())); + } + + private static String canonicalMap(Map map) { + if (map == null || map.isEmpty()) { + return ""; + } + return new TreeMap<>(map) + .entrySet().stream() + .map(entry -> entry.getKey() + "=" + normalized(entry.getValue())) + .collect(Collectors.joining(",")); + } + + private static String normalized(Object value) { + return value == null ? "" : value.toString().trim(); + } + + private static String clamp(String text) { + String normalized = text.replaceAll("\\s+", " ").trim(); + if (normalized.length() <= MAX_EVENT_FIELD_CHARS) { + return normalized; + } + return normalized.substring(0, MAX_EVENT_FIELD_CHARS) + "..."; + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEvent.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEvent.java new file mode 100644 index 00000000..77dabaff --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEvent.java @@ -0,0 +1,42 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.model; + +import java.time.Instant; +import java.util.Map; + +/** + * One normalized event from an agent session timeline. + */ +public record AgentEvent( + String id, + Integer seq, + AgentEventKind kind, + Instant occurredAt, + String text, + String toolName, + String input, + String output, + AgentEventStatus status, + Long durationMs, + String path, + String operation, + String command, + Integer exitCode, + Map metadata) { + + public AgentEvent { + metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEventKind.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEventKind.java new file mode 100644 index 00000000..3b15b794 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEventKind.java @@ -0,0 +1,50 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.Locale; + +/** + * Agent timeline event kind. + */ +public enum AgentEventKind { + USER_PROMPT, + ASSISTANT_MESSAGE, + TOOL_CALL, + TOOL_RESULT, + COMMAND, + FILE_READ, + FILE_EDIT, + TEST_RESULT, + PERMISSION_REQUEST, + ERROR, + STOP, + SESSION_END, + TASK_COMPLETED; + + @JsonCreator + public static AgentEventKind fromWireValue(String value) { + if (value == null || value.isBlank()) { + return null; + } + return AgentEventKind.valueOf(value.trim().replace('-', '_').toUpperCase(Locale.ROOT)); + } + + @JsonValue + public String wireValue() { + return name().toLowerCase(Locale.ROOT); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEventStatus.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEventStatus.java new file mode 100644 index 00000000..b3230955 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEventStatus.java @@ -0,0 +1,42 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.Locale; + +/** + * Agent event execution status. + */ +public enum AgentEventStatus { + SUCCESS, + FAILED, + CANCELLED, + RUNNING, + UNKNOWN; + + @JsonCreator + public static AgentEventStatus fromWireValue(String value) { + if (value == null || value.isBlank()) { + return null; + } + return AgentEventStatus.valueOf(value.trim().replace('-', '_').toUpperCase(Locale.ROOT)); + } + + @JsonValue + public String wireValue() { + return name().toLowerCase(Locale.ROOT); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentGitContext.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentGitContext.java new file mode 100644 index 00000000..21b40011 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentGitContext.java @@ -0,0 +1,19 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.model; + +/** + * Git state associated with an agent timeline. + */ +public record AgentGitContext(String branch, String commit, Boolean dirty) {} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentProject.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentProject.java new file mode 100644 index 00000000..7cfdfcc2 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentProject.java @@ -0,0 +1,37 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.model; + +import java.util.Map; + +/** + * Project context for an agent timeline. + */ +public record AgentProject( + String name, String rootPath, AgentGitContext git, Map metadata) { + + public AgentProject { + metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + } + + public String toDisplayString() { + if (rootPath == null || rootPath.isBlank()) { + return name; + } + if (name == null || name.isBlank()) { + return rootPath; + } + return name + " (" + rootPath + ")"; + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/AgentRawContentTypeRegistrarTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/AgentRawContentTypeRegistrarTest.java new file mode 100644 index 00000000..70e845d8 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/AgentRawContentTypeRegistrarTest.java @@ -0,0 +1,28 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.openmemind.ai.memory.plugin.rawdata.agent.content.AgentTimelineContent; +import org.junit.jupiter.api.Test; + +class AgentRawContentTypeRegistrarTest { + + @Test + void subtypesShouldRegisterAgentTimelineRawContent() { + assertThat(new AgentRawContentTypeRegistrar().subtypes()) + .containsEntry("agent_timeline", AgentTimelineContent.class); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java new file mode 100644 index 00000000..c341c45e --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java @@ -0,0 +1,130 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.content; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.openmemind.ai.memory.core.extraction.rawdata.RawContentJackson; +import com.openmemind.ai.memory.core.extraction.rawdata.content.RawContent; +import com.openmemind.ai.memory.plugin.rawdata.agent.AgentRawContentTypeRegistrar; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEvent; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventKind; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventStatus; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentProject; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; + +class AgentTimelineContentTest { + + private static final ObjectMapper OBJECT_MAPPER = createObjectMapper(); + + private static ObjectMapper createObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + return RawContentJackson.registerAll(mapper, List.of(new AgentRawContentTypeRegistrar())); + } + + @Test + void contentShouldExposeDeterministicIdentityAndReadableTimelineText() { + AgentProject project = new AgentProject("payment-service", "/repo/payment", null, Map.of()); + List events = + List.of( + new AgentEvent( + "e2", + 2, + AgentEventKind.COMMAND, + Instant.parse("2026-05-24T10:01:00Z"), + null, + "Bash", + null, + "rounding mismatch", + AgentEventStatus.FAILED, + 1200L, + null, + null, + "npm test payment", + 1, + Map.of()), + new AgentEvent( + "e1", + 1, + AgentEventKind.USER_PROMPT, + Instant.parse("2026-05-24T10:00:00Z"), + "Fix payment tests", + null, + null, + null, + null, + null, + null, + null, + null, + null, + Map.of())); + + AgentTimelineContent content = + new AgentTimelineContent( + "claude-code", "1.0", "session-123", "timeline-123", project, events); + AgentTimelineContent duplicate = + new AgentTimelineContent( + "claude-code", "1.0", "session-123", "timeline-123", project, events); + + assertThat(content.contentType()).isEqualTo("AGENT_TIMELINE"); + assertThat(content.toContentString()) + .contains("Goal:", "Fix payment tests", "npm test payment"); + assertThat(content.getContentId()).isEqualTo(duplicate.getContentId()); + assertThat(content.events()).extracting(AgentEvent::id).containsExactly("e1", "e2"); + } + + @Test + void jacksonRoundTripShouldPreserveSubtypeAndUserPromptText() throws Exception { + AgentTimelineContent content = + new AgentTimelineContent( + "codex", + "1.0", + "session-1", + "timeline-1", + new AgentProject("memind", "/repo/memind", null, Map.of()), + List.of( + new AgentEvent( + "e1", + 1, + AgentEventKind.USER_PROMPT, + Instant.parse("2026-05-24T10:00:00Z"), + "Review rawdata-agent design", + null, + null, + null, + null, + null, + null, + null, + null, + null, + Map.of()))); + + String json = OBJECT_MAPPER.writeValueAsString(content); + RawContent decoded = OBJECT_MAPPER.readValue(json, RawContent.class); + + assertThat(json).contains("\"type\":\"agent_timeline\""); + assertThat(decoded).isInstanceOf(AgentTimelineContent.class); + assertThat(((AgentTimelineContent) decoded).events()) + .singleElement() + .extracting(AgentEvent::text) + .isEqualTo("Review rawdata-agent design"); + assertThat(decoded.toContentString()).contains("Goal: Review rawdata-agent design"); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/pom.xml b/memind-plugins/memind-plugin-rawdatas/pom.xml index bf3ae51e..dfbc49cb 100644 --- a/memind-plugins/memind-plugin-rawdatas/pom.xml +++ b/memind-plugins/memind-plugin-rawdatas/pom.xml @@ -34,5 +34,6 @@ memind-plugin-rawdata-image memind-plugin-rawdata-document memind-plugin-rawdata-toolcall + memind-plugin-rawdata-agent From 375b5ca7009dc72ec0d48efb7f1081e8733aadfb Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 25 May 2026 10:28:50 +0800 Subject: [PATCH 06/54] feat(rawdata-agent): redact sensitive agent events --- .../agent/config/AgentPrivacyOptions.java | 39 +++ .../agent/privacy/AgentEventRedactor.java | 223 ++++++++++++++++++ .../agent/privacy/SecretPatternRedactor.java | 137 +++++++++++ .../agent/privacy/AgentEventRedactorTest.java | 151 ++++++++++++ .../privacy/SecretPatternRedactorTest.java | 57 +++++ 5 files changed, 607 insertions(+) create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/config/AgentPrivacyOptions.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactor.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/SecretPatternRedactor.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactorTest.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/SecretPatternRedactorTest.java diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/config/AgentPrivacyOptions.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/config/AgentPrivacyOptions.java new file mode 100644 index 00000000..19766c89 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/config/AgentPrivacyOptions.java @@ -0,0 +1,39 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.config; + +import java.util.List; + +/** + * Privacy controls for coding-agent timeline capture. + */ +public record AgentPrivacyOptions( + boolean redactSecrets, + int maxInputChars, + int maxOutputChars, + boolean captureFileContent, + List denyPathPatterns, + List allowPathPatterns) { + + public AgentPrivacyOptions() { + this(true, 2_000, 4_000, false, List.of(".env", "*.pem", "*.key"), List.of()); + } + + public AgentPrivacyOptions { + maxInputChars = Math.max(0, maxInputChars); + maxOutputChars = Math.max(0, maxOutputChars); + denyPathPatterns = denyPathPatterns == null ? List.of() : List.copyOf(denyPathPatterns); + allowPathPatterns = allowPathPatterns == null ? List.of() : List.copyOf(allowPathPatterns); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactor.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactor.java new file mode 100644 index 00000000..e96960cd --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactor.java @@ -0,0 +1,223 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.privacy; + +import com.openmemind.ai.memory.plugin.rawdata.agent.config.AgentPrivacyOptions; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEvent; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventKind; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Applies privacy redaction to normalized agent events. + */ +public final class AgentEventRedactor { + + private static final String FILE_CONTENT_PLACEHOLDER = "[REDACTED:file_content]"; + private static final String TRUNCATED_PLACEHOLDER = "truncated"; + + private final AgentPrivacyOptions options; + private final SecretPatternRedactor secretRedactor; + + public AgentEventRedactor() { + this(new AgentPrivacyOptions()); + } + + public AgentEventRedactor(AgentPrivacyOptions options) { + this(options, new SecretPatternRedactor()); + } + + public AgentEventRedactor(AgentPrivacyOptions options, SecretPatternRedactor secretRedactor) { + this.options = options == null ? new AgentPrivacyOptions() : options; + this.secretRedactor = secretRedactor == null ? new SecretPatternRedactor() : secretRedactor; + } + + public AgentEvent redact(AgentEvent event) { + if (event == null) { + return null; + } + + var state = new RedactionState(); + boolean dropFileContent = shouldDropFileContent(event); + String text = redactSecrets(event.text(), state); + String input = + dropFileContent + ? redactFileContent(event.input(), state) + : truncate( + redactSecrets(event.input(), state), + options.maxInputChars(), + state); + String output = + dropFileContent + ? redactFileContent(event.output(), state) + : truncate( + redactSecrets(event.output(), state), + options.maxOutputChars(), + state); + String command = redactSecrets(event.command(), state); + + Map metadata = mergeMetadata(event.metadata(), state); + return new AgentEvent( + event.id(), + event.seq(), + event.kind(), + event.occurredAt(), + text, + event.toolName(), + input, + output, + event.status(), + event.durationMs(), + event.path(), + event.operation(), + command, + event.exitCode(), + metadata); + } + + private String redactSecrets(String value, RedactionState state) { + if (value == null || !options.redactSecrets()) { + return value; + } + SecretPatternRedactor.RedactionResult result = secretRedactor.redact(value); + if (result.redacted()) { + state.redacted = true; + result.redactionKinds().forEach(state.redactionKinds::add); + } + return result.text(); + } + + private static String truncate(String value, int maxChars, RedactionState state) { + if (value == null || value.length() <= maxChars) { + return value; + } + state.redacted = true; + state.truncated = true; + state.redactionKinds.add(TRUNCATED_PLACEHOLDER); + return value.substring(0, maxChars); + } + + private static String redactFileContent(String value, RedactionState state) { + if (value == null) { + return null; + } + state.redacted = true; + state.redactionKinds.add("file_content"); + return FILE_CONTENT_PLACEHOLDER; + } + + private Map mergeMetadata(Map existing, RedactionState state) { + if (!state.redacted && !state.truncated) { + return existing == null ? Map.of() : Map.copyOf(existing); + } + var metadata = new LinkedHashMap(); + if (existing != null) { + metadata.putAll(existing); + } + metadata.put("redacted", true); + if (state.truncated) { + metadata.put("truncated", true); + } + if (!state.redactionKinds.isEmpty()) { + metadata.put("redactionKinds", java.util.List.copyOf(state.redactionKinds)); + } + return Map.copyOf(metadata); + } + + private boolean shouldDropFileContent(AgentEvent event) { + if (event.kind() != AgentEventKind.FILE_READ && event.kind() != AgentEventKind.FILE_EDIT) { + return false; + } + if (matchesAny(options.allowPathPatterns(), event.path())) { + return false; + } + if (!options.captureFileContent()) { + return true; + } + return matchesAny(options.denyPathPatterns(), event.path()); + } + + private static boolean matchesAny(java.util.List patterns, String path) { + if (path == null || path.isBlank() || patterns == null || patterns.isEmpty()) { + return false; + } + return patterns.stream().anyMatch(pattern -> matchesPathPattern(pattern, path)); + } + + private static boolean matchesPathPattern(String pattern, String path) { + if (pattern == null || pattern.isBlank()) { + return false; + } + String normalizedPattern = normalizePath(pattern); + String normalizedPath = normalizePath(path); + String fileName = fileName(normalizedPath); + if (!hasGlobSyntax(normalizedPattern)) { + return normalizedPath.equals(normalizedPattern) + || normalizedPath.endsWith("/" + normalizedPattern) + || fileName.equals(normalizedPattern); + } + String regex = globToRegex(normalizedPattern); + return normalizedPath.matches(regex) || fileName.matches(regex); + } + + private static String normalizePath(String value) { + return value.replace('\\', '/'); + } + + private static String fileName(String path) { + int index = path.lastIndexOf('/'); + return index < 0 ? path : path.substring(index + 1); + } + + private static boolean hasGlobSyntax(String pattern) { + return pattern.indexOf('*') >= 0 || pattern.indexOf('?') >= 0; + } + + private static String globToRegex(String pattern) { + StringBuilder regex = new StringBuilder("^"); + for (int index = 0; index < pattern.length(); index++) { + char ch = pattern.charAt(index); + if (ch == '*') { + boolean doubleStar = + index + 1 < pattern.length() && pattern.charAt(index + 1) == '*'; + regex.append(doubleStar ? ".*" : "[^/]*"); + if (doubleStar) { + index++; + } + } else if (ch == '?') { + regex.append("[^/]"); + } else { + appendRegexLiteral(regex, ch); + } + } + regex.append('$'); + return regex.toString(); + } + + private static void appendRegexLiteral(StringBuilder regex, char ch) { + if ("\\.[]{}()+-^$|".indexOf(ch) >= 0) { + regex.append('\\'); + } + regex.append(String.valueOf(ch).toLowerCase(Locale.ROOT)); + } + + private static final class RedactionState { + private final Set redactionKinds = new LinkedHashSet<>(); + private boolean redacted; + private boolean truncated; + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/SecretPatternRedactor.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/SecretPatternRedactor.java new file mode 100644 index 00000000..b3c2e08e --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/SecretPatternRedactor.java @@ -0,0 +1,137 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.privacy; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Pattern-based redactor for secrets commonly present in coding-agent events. + */ +public final class SecretPatternRedactor { + + private static final List RULES = + List.of( + new RedactionRule( + "bearer_token", + Pattern.compile("(?i)\\bBearer\\s+([A-Za-z0-9._~+/=-]{6,})"), + match -> "Bearer [REDACTED:bearer_token]"), + new RedactionRule( + "database_url", + Pattern.compile( + "(?i)\\b((?:[A-Z0-9_]*DATABASE_URL|DB_URL)\\s*=\\s*)?" + + "(?:jdbc:)?(?:postgres(?:ql)?|mysql|mariadb|" + + "mongodb(?:\\+srv)?|redis|rediss|amqp|amqps)" + + "://[^\\s/@:]+:[^\\s/@]+@[^\\s]+"), + match -> prefix(match, 1) + "[REDACTED:database_url]"), + new RedactionRule( + "private_key", + Pattern.compile( + "-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*?" + + "(?:-----END [A-Z ]*PRIVATE KEY-----|\\z)"), + match -> "[REDACTED:private_key]"), + new RedactionRule( + "api_key", + Pattern.compile( + "(?i)\\b([A-Z0-9_]*(?:API_KEY|APIKEY)\\s*=\\s*)" + + "(?!\\[REDACTED:)[^\\s'\";]+"), + match -> prefix(match, 1) + "[REDACTED:api_key]"), + new RedactionRule( + "cloud_credential", + Pattern.compile( + "(?i)\\b((?:AWS|AZURE|GOOGLE|GCP)_[A-Z0-9_]*" + + "(?:SECRET|KEY|TOKEN|CREDENTIAL)[A-Z0-9_]*" + + "\\s*=\\s*)(?!\\[REDACTED:)[^\\s'\";]+"), + match -> prefix(match, 1) + "[REDACTED:cloud_credential]"), + new RedactionRule( + "cloud_credential", + Pattern.compile("\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b"), + match -> "[REDACTED:cloud_credential]"), + new RedactionRule( + "secret_env", + Pattern.compile( + "(?i)\\b([A-Z0-9_]*(?:PASSWORD|SECRET|TOKEN|PRIVATE_KEY)" + + "[A-Z0-9_]*\\s*=\\s*)(?!\\[REDACTED:)" + + "[^\\s'\";]+"), + match -> prefix(match, 1) + "[REDACTED:secret_env]")); + + public RedactionResult redact(String text) { + if (text == null || text.isEmpty()) { + return new RedactionResult(text, List.of()); + } + + String redacted = text; + Set kinds = new LinkedHashSet<>(); + for (RedactionRule rule : RULES) { + RedactionPass pass = applyRule(redacted, rule); + redacted = pass.text(); + if (pass.redacted()) { + kinds.add(rule.kind()); + } + } + return new RedactionResult(redacted, List.copyOf(kinds)); + } + + private static RedactionPass applyRule(String text, RedactionRule rule) { + Matcher matcher = rule.pattern().matcher(text); + StringBuilder builder = null; + boolean redacted = false; + while (matcher.find()) { + if (builder == null) { + builder = new StringBuilder(text.length()); + } + redacted = true; + matcher.appendReplacement( + builder, Matcher.quoteReplacement(rule.replacement().replace(matcher))); + } + if (!redacted) { + return new RedactionPass(text, false); + } + matcher.appendTail(builder); + return new RedactionPass(builder.toString(), true); + } + + private static String prefix(Matcher matcher, int group) { + String value = matcher.group(group); + return value == null ? "" : value; + } + + private record RedactionRule(String kind, Pattern pattern, Replacement replacement) {} + + private record RedactionPass(String text, boolean redacted) {} + + @FunctionalInterface + private interface Replacement { + String replace(Matcher matcher); + } + + public record RedactionResult(String text, List redactionKinds) { + + public RedactionResult { + redactionKinds = redactionKinds == null ? List.of() : List.copyOf(redactionKinds); + } + + public boolean redacted() { + return !redactionKinds.isEmpty(); + } + + public List mutableKindsCopy() { + return new ArrayList<>(redactionKinds); + } + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactorTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactorTest.java new file mode 100644 index 00000000..a2a00d2c --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactorTest.java @@ -0,0 +1,151 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.privacy; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.openmemind.ai.memory.plugin.rawdata.agent.config.AgentPrivacyOptions; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEvent; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventKind; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventStatus; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class AgentEventRedactorTest { + + @Test + void shouldTruncateLongCommandOutputAndMarkEventAsRedacted() { + AgentEvent event = commandWithOutput("npm test", "ok ".repeat(5000)); + + AgentEvent redacted = new AgentEventRedactor().redact(event); + + assertThat(redacted.output()).hasSizeLessThanOrEqualTo(4000); + assertThat(redacted.metadata()).containsEntry("redacted", true); + assertThat(redacted.metadata()).containsEntry("truncated", true); + } + + @Test + void shouldRedactSecretsFromInputOutputAndMetadata() { + AgentEvent event = + new AgentEvent( + "e1", + 1, + AgentEventKind.COMMAND, + Instant.parse("2026-05-24T10:00:00Z"), + "Run deployment", + "Bash", + "Authorization: Bearer abc.def.ghi", + "DATABASE_URL=postgres://u:p@example/db", + AgentEventStatus.SUCCESS, + 12L, + null, + null, + "deploy", + 0, + Map.of("existing", "value")); + + AgentEvent redacted = new AgentEventRedactor().redact(event); + + assertThat(redacted.input()).contains("[REDACTED:bearer_token]"); + assertThat(redacted.output()).contains("[REDACTED:database_url]"); + assertThat(redacted.metadata()) + .containsEntry("existing", "value") + .containsEntry("redacted", true); + assertThat(redacted.metadata().get("redactionKinds")) + .asList() + .containsExactly("bearer_token", "database_url"); + } + + @Test + void shouldDropFileContentForSensitivePathsByDefault() { + AgentEvent event = + new AgentEvent( + "e1", + 1, + AgentEventKind.FILE_READ, + Instant.parse("2026-05-24T10:00:00Z"), + null, + "Read", + "PRIVATE=secret", + "secret file body", + AgentEventStatus.SUCCESS, + 12L, + "/repo/.env", + "read", + null, + null, + Map.of()); + + AgentEvent redacted = new AgentEventRedactor().redact(event); + + assertThat(redacted.input()).isEqualTo("[REDACTED:file_content]"); + assertThat(redacted.output()).isEqualTo("[REDACTED:file_content]"); + assertThat(redacted.metadata().get("redactionKinds")) + .asList() + .containsExactly("file_content"); + } + + @Test + void shouldKeepFileContentWhenCaptureIsAllowedAndPathIsAllowed() { + AgentPrivacyOptions options = + new AgentPrivacyOptions( + true, 2000, 4000, true, List.of(".env"), List.of("fixtures/.env")); + AgentEvent event = + new AgentEvent( + "e1", + 1, + AgentEventKind.FILE_READ, + Instant.parse("2026-05-24T10:00:00Z"), + null, + "Read", + "fixture", + "DATABASE_URL=postgres://u:p@example/db", + AgentEventStatus.SUCCESS, + 12L, + "/repo/fixtures/.env", + "read", + null, + null, + Map.of()); + + AgentEvent redacted = new AgentEventRedactor(options).redact(event); + + assertThat(redacted.input()).isEqualTo("fixture"); + assertThat(redacted.output()).contains("[REDACTED:database_url]"); + assertThat(redacted.metadata().get("redactionKinds")) + .asList() + .containsExactly("database_url"); + } + + private static AgentEvent commandWithOutput(String command, String output) { + return new AgentEvent( + "e1", + 1, + AgentEventKind.COMMAND, + Instant.parse("2026-05-24T10:00:00Z"), + null, + "Bash", + null, + output, + AgentEventStatus.SUCCESS, + 42L, + null, + null, + command, + 0, + Map.of()); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/SecretPatternRedactorTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/SecretPatternRedactorTest.java new file mode 100644 index 00000000..46bdf688 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/SecretPatternRedactorTest.java @@ -0,0 +1,57 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.privacy; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class SecretPatternRedactorTest { + + private final SecretPatternRedactor redactor = new SecretPatternRedactor(); + + @Test + void shouldRedactBearerTokensDatabaseUrlsAndPrivateKeys() { + assertThat(redactor.redact("Authorization: Bearer abc.def.ghi").text()) + .contains("[REDACTED:bearer_token]"); + assertThat(redactor.redact("DATABASE_URL=postgres://u:p@example/db").text()) + .contains("[REDACTED:database_url]"); + assertThat(redactor.redact("-----BEGIN PRIVATE KEY-----\nabc").text()) + .contains("[REDACTED:private_key]"); + } + + @Test + void shouldReportAllRedactionKindsInStableOrder() { + SecretPatternRedactor.RedactionResult result = + redactor.redact( + "Authorization: Bearer abc.def.ghi\n" + + "OPENAI_API_KEY=sk-test-token\n" + + "AWS_SECRET_ACCESS_KEY=secret-value"); + + assertThat(result.redacted()).isTrue(); + assertThat(result.redactionKinds()) + .containsExactly("bearer_token", "api_key", "cloud_credential"); + assertThat(result.text()).doesNotContain("abc.def.ghi", "sk-test-token", "secret-value"); + } + + @Test + void shouldReturnOriginalTextWhenNoSecretMatches() { + SecretPatternRedactor.RedactionResult result = redactor.redact("npm test passed"); + + assertThat(result.text()).isEqualTo("npm test passed"); + assertThat(result.redacted()).isFalse(); + assertThat(result.redactionKinds()).isEqualTo(List.of()); + } +} From 703dfdfe897b63e9c3701558b6f135e840927e0b Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 25 May 2026 10:41:45 +0800 Subject: [PATCH 07/54] feat(rawdata-agent): assemble agent episodes --- .../agent/caption/AgentCaptionGenerator.java | 83 ++++ .../agent/chunk/AgentEpisodeAssembler.java | 432 ++++++++++++++++++ .../agent/chunk/AgentSegmentFormatter.java | 189 ++++++++ .../agent/chunk/AgentTimelineChunker.java | 84 ++++ .../agent/config/AgentChunkingOptions.java | 39 ++ .../rawdata/agent/model/AgentCommand.java | 35 ++ .../rawdata/agent/model/AgentEpisode.java | 54 +++ .../agent/model/AgentFileReference.java | 19 + .../rawdata/agent/model/AgentOutcome.java | 42 ++ .../rawdata/agent/model/AgentToolCall.java | 20 + .../caption/AgentCaptionGeneratorTest.java | 46 ++ .../chunk/AgentEpisodeAssemblerTest.java | 235 ++++++++++ .../agent/chunk/AgentEpisodeTestSupport.java | 138 ++++++ .../chunk/AgentSegmentFormatterTest.java | 69 +++ .../agent/chunk/AgentTimelineChunkerTest.java | 70 +++ 15 files changed, 1555 insertions(+) create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGenerator.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentTimelineChunker.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/config/AgentChunkingOptions.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentCommand.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEpisode.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentFileReference.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentOutcome.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentToolCall.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGeneratorTest.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeTestSupport.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentTimelineChunkerTest.java diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGenerator.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGenerator.java new file mode 100644 index 00000000..6562a318 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGenerator.java @@ -0,0 +1,83 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.caption; + +import com.openmemind.ai.memory.core.extraction.rawdata.caption.CaptionGenerator; +import java.util.List; +import java.util.Map; +import reactor.core.publisher.Mono; + +/** + * Deterministic caption generator for agent episode segments. + */ +public final class AgentCaptionGenerator implements CaptionGenerator { + + @Override + public Mono generate(String content, Map metadata) { + return Mono.just(caption(content, metadata)); + } + + @Override + public Mono generate(String content, Map metadata, String language) { + return generate(content, metadata); + } + + private String caption(String content, Map metadata) { + if (metadata == null || metadata.isEmpty()) { + return truncate(content, 160); + } + String goal = stringValue(metadata.get("goal")); + String outcome = stringValue(metadata.get("outcome")); + String summary = summary(metadata); + String base = + "Agent episode: " + + (goal.isBlank() ? "unknown goal" : goal) + + " -> " + + (outcome.isBlank() ? "unknown" : outcome); + if (summary.isBlank()) { + return base; + } + return base + " (" + summary + ")"; + } + + private String summary(Map metadata) { + String file = first(metadata.get("files")); + String command = first(metadata.get("commands")); + if (!file.isBlank() && !command.isBlank()) { + return file + "; " + command; + } + if (!file.isBlank()) { + return file; + } + return command; + } + + private String first(Object value) { + if (value instanceof List list && !list.isEmpty() && list.getFirst() != null) { + return list.getFirst().toString(); + } + return ""; + } + + private String stringValue(Object value) { + return value == null ? "" : value.toString(); + } + + private static String truncate(String content, int maxChars) { + if (content == null) { + return ""; + } + return content.length() <= maxChars ? content : content.substring(0, maxChars); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java new file mode 100644 index 00000000..a2515b31 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java @@ -0,0 +1,432 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.chunk; + +import com.openmemind.ai.memory.core.utils.HashUtils; +import com.openmemind.ai.memory.core.utils.TokenUtils; +import com.openmemind.ai.memory.plugin.rawdata.agent.config.AgentChunkingOptions; +import com.openmemind.ai.memory.plugin.rawdata.agent.content.AgentTimelineContent; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentCommand; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEpisode; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEvent; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventKind; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventStatus; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentFileReference; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentOutcome; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentToolCall; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Builds deterministic task episodes from normalized timeline events. + */ +public final class AgentEpisodeAssembler { + + private static final List PHASE_ORDER = + List.of("investigation", "implementation", "validation", "handoff"); + + private final AgentChunkingOptions options; + + public AgentEpisodeAssembler() { + this(AgentChunkingOptions.defaults()); + } + + public AgentEpisodeAssembler(AgentChunkingOptions options) { + this.options = options == null ? AgentChunkingOptions.defaults() : options; + } + + public List assemble(AgentTimelineContent timeline) { + if (timeline == null || timeline.events().isEmpty()) { + return List.of(); + } + List baseEpisodes = buildBaseEpisodes(timeline, sorted(timeline.events())); + List result = new ArrayList<>(); + for (AgentEpisode episode : baseEpisodes) { + if (TokenUtils.countTokens(episodeText(episode)) > options.targetEpisodeTokens()) { + result.addAll(splitByPhase(timeline, episode)); + } else { + result.add(episode); + } + } + return List.copyOf(result); + } + + private List buildBaseEpisodes( + AgentTimelineContent timeline, List events) { + List episodes = new ArrayList<>(); + List current = new ArrayList<>(); + AgentEvent previous = null; + String currentTaskKey = null; + + for (AgentEvent event : events) { + boolean startsNewPrompt = + event.kind() == AgentEventKind.USER_PROMPT && !current.isEmpty(); + boolean crossesBoundary = + !current.isEmpty() + && (startsNewPrompt + || exceedsGap(previous, event) + || exceedsEventLimit(current) + || taskKeyChanged(currentTaskKey, taskKey(event))); + if (crossesBoundary) { + episodes.add(buildEpisode(timeline, current, "full", Map.of())); + current = new ArrayList<>(); + currentTaskKey = null; + } + + current.add(event); + if (currentTaskKey == null) { + currentTaskKey = taskKey(event); + } + previous = event; + + if (isTerminal(event)) { + episodes.add(buildEpisode(timeline, current, "full", Map.of())); + current = new ArrayList<>(); + currentTaskKey = null; + previous = null; + } + } + + if (!current.isEmpty()) { + episodes.add(buildEpisode(timeline, current, "full", Map.of())); + } + return episodes; + } + + private AgentEpisode buildEpisode( + AgentTimelineContent timeline, + List rawEvents, + String phase, + Map extraMetadata) { + List events = sorted(rawEvents); + List eventIds = events.stream().map(AgentEvent::id).filter(this::hasText).toList(); + List commandEvents = commandEvents(events); + List fileReferences = fileReferences(events); + List toolCalls = toolCalls(events); + List commands = + distinct(commandEvents.stream().map(AgentCommand::command).toList()); + List files = + distinct(fileReferences.stream().map(AgentFileReference::path).toList()); + List toolNames = distinct(toolCalls.stream().map(AgentToolCall::toolName).toList()); + List failureSignals = failureSignals(events); + AgentOutcome outcome = outcome(events); + Instant startTime = firstTime(events); + Instant endTime = lastTime(events); + String id = episodeId(timeline, eventIds); + var metadata = new LinkedHashMap(); + metadata.putAll(extraMetadata); + return new AgentEpisode( + id, + goal(events), + outcome, + phase, + events, + eventIds, + files, + fileReferences, + commands, + commandEvents, + toolNames, + toolCalls, + failureSignals, + startTime, + endTime, + metadata); + } + + private List splitByPhase(AgentTimelineContent timeline, AgentEpisode episode) { + var byPhase = new LinkedHashMap>(); + PHASE_ORDER.forEach(phase -> byPhase.put(phase, new ArrayList<>())); + for (AgentEvent event : episode.events()) { + byPhase.get(phase(event)).add(event); + } + List split = new ArrayList<>(); + for (String phase : PHASE_ORDER) { + List phaseEvents = byPhase.get(phase); + if (!phaseEvents.isEmpty()) { + split.add(phaseEpisode(timeline, episode, phaseEvents, phase)); + } + } + return List.copyOf(split); + } + + private AgentEpisode phaseEpisode( + AgentTimelineContent timeline, + AgentEpisode parent, + List phaseEvents, + String phase) { + AgentEpisode local = buildEpisode(timeline, phaseEvents, phase, Map.of("phaseSplit", true)); + return new AgentEpisode( + local.id(), + parent.goal(), + parent.outcome(), + local.phase(), + local.events(), + local.eventIds(), + local.files(), + local.fileReferences(), + local.commands(), + local.commandEvents(), + local.toolNames(), + local.toolCalls(), + local.failureSignals(), + local.startTime(), + local.endTime(), + local.metadata()); + } + + private String episodeId(AgentTimelineContent timeline, List eventIds) { + String firstEventId = eventIds.isEmpty() ? "" : eventIds.getFirst(); + String lastEventId = eventIds.isEmpty() ? "" : eventIds.getLast(); + return HashUtils.sampledSha256( + String.join( + "|", + normalized(timeline.sourceClient()), + normalized(timeline.sessionId()), + firstEventId, + lastEventId, + String.join(",", eventIds))); + } + + private List commandEvents(List events) { + return events.stream() + .filter(event -> hasText(event.command())) + .map( + event -> + new AgentCommand( + event.command(), + event.status(), + event.output(), + event.exitCode(), + event.seq(), + event.id())) + .toList(); + } + + private List fileReferences(List events) { + return events.stream() + .filter(event -> hasText(event.path())) + .map( + event -> + new AgentFileReference( + event.path(), event.operation(), event.seq(), event.id())) + .toList(); + } + + private List toolCalls(List events) { + return events.stream() + .filter(event -> hasText(event.toolName())) + .map( + event -> + new AgentToolCall( + event.toolName(), event.status(), event.seq(), event.id())) + .toList(); + } + + private List failureSignals(List events) { + var signals = new LinkedHashSet(); + for (AgentEvent event : events) { + if (event.status() == AgentEventStatus.FAILED) { + addSignal(signals, event.output()); + addSignal(signals, event.text()); + } + Object failureSignal = event.metadata().get("failureSignal"); + if (failureSignal != null) { + addSignal(signals, failureSignal.toString()); + } + } + return List.copyOf(signals); + } + + private static void addSignal(LinkedHashSet signals, String value) { + String normalized = concise(value); + if (!normalized.isBlank()) { + signals.add(normalized); + } + } + + private AgentOutcome outcome(List events) { + boolean success = + events.stream() + .anyMatch( + event -> + (isTerminal(event) + || event.kind() == AgentEventKind.COMMAND + || event.kind() + == AgentEventKind.TEST_RESULT) + && event.status() == AgentEventStatus.SUCCESS); + boolean failed = + events.stream().anyMatch(event -> event.status() == AgentEventStatus.FAILED); + boolean cancelled = + events.stream().anyMatch(event -> event.status() == AgentEventStatus.CANCELLED); + if (success && failed) { + return AgentOutcome.SUCCESS; + } + if (success) { + return AgentOutcome.SUCCESS; + } + if (cancelled) { + return AgentOutcome.CANCELLED; + } + if (failed) { + return AgentOutcome.FAILED; + } + return AgentOutcome.UNKNOWN; + } + + private String goal(List events) { + return events.stream() + .filter(event -> event.kind() == AgentEventKind.USER_PROMPT) + .map(AgentEvent::text) + .filter(this::hasText) + .findFirst() + .orElse(""); + } + + private String phase(AgentEvent event) { + if (event.kind() == AgentEventKind.FILE_EDIT) { + return "implementation"; + } + if (event.kind() == AgentEventKind.STOP + || event.kind() == AgentEventKind.SESSION_END + || event.kind() == AgentEventKind.TASK_COMPLETED + || event.kind() == AgentEventKind.ASSISTANT_MESSAGE) { + return "handoff"; + } + if ((event.kind() == AgentEventKind.COMMAND || event.kind() == AgentEventKind.TEST_RESULT) + && event.status() == AgentEventStatus.SUCCESS) { + return "validation"; + } + return "investigation"; + } + + private String episodeText(AgentEpisode episode) { + var lines = new ArrayList(); + lines.add(episode.goal()); + lines.addAll(episode.failureSignals()); + lines.addAll(episode.files()); + lines.addAll(episode.commands()); + lines.addAll( + episode.events().stream() + .map( + event -> + String.join( + " ", + normalized(event.text()), + normalized(event.output()))) + .toList()); + return String.join("\n", lines); + } + + private boolean exceedsGap(AgentEvent previous, AgentEvent event) { + return previous != null + && previous.occurredAt() != null + && event.occurredAt() != null + && Duration.between(previous.occurredAt(), event.occurredAt()) + .compareTo(options.maxEventGap()) + > 0; + } + + private boolean exceedsEventLimit(List current) { + return current.size() >= options.maxEventsPerEpisode(); + } + + private boolean taskKeyChanged(String currentTaskKey, String nextTaskKey) { + return hasText(currentTaskKey) + && hasText(nextTaskKey) + && !currentTaskKey.equals(nextTaskKey); + } + + private String taskKey(AgentEvent event) { + Object taskId = event.metadata().get("taskId"); + Object subtaskId = event.metadata().get("subtaskId"); + if (taskId == null && subtaskId == null) { + return null; + } + return normalized(taskId) + "|" + normalized(subtaskId); + } + + private static boolean isTerminal(AgentEvent event) { + return event.kind() == AgentEventKind.STOP + || event.kind() == AgentEventKind.SESSION_END + || event.kind() == AgentEventKind.TASK_COMPLETED; + } + + private Instant firstTime(List events) { + return events.stream() + .map(AgentEvent::occurredAt) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } + + private Instant lastTime(List events) { + Instant last = null; + for (AgentEvent event : events) { + if (event.occurredAt() != null) { + last = event.occurredAt(); + } + } + return last; + } + + private List sorted(List events) { + if (events == null || events.isEmpty()) { + return List.of(); + } + return events.stream() + .filter(Objects::nonNull) + .sorted( + Comparator.comparing( + AgentEvent::seq, Comparator.nullsLast(Integer::compareTo)) + .thenComparing( + AgentEvent::occurredAt, + Comparator.nullsLast(Instant::compareTo)) + .thenComparing( + AgentEvent::id, Comparator.nullsLast(String::compareTo))) + .toList(); + } + + private List distinct(List values) { + var seen = new LinkedHashSet(); + values.stream().filter(this::hasText).forEach(seen::add); + return List.copyOf(seen); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private static String normalized(Object value) { + return value == null ? "" : value.toString().trim(); + } + + private static String concise(String value) { + if (value == null) { + return ""; + } + String normalized = value.replaceAll("\\s+", " ").trim(); + if (normalized.length() <= 180) { + return normalized; + } + return normalized.substring(0, 180); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java new file mode 100644 index 00000000..d4b626d4 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java @@ -0,0 +1,189 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.chunk; + +import com.openmemind.ai.memory.core.utils.HashUtils; +import com.openmemind.ai.memory.plugin.rawdata.agent.content.AgentTimelineContent; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentCommand; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEpisode; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEvent; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventStatus; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentProject; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Formats agent episodes into deterministic segment text and metadata. + */ +public final class AgentSegmentFormatter { + + public FormattedSegment format(AgentTimelineContent timeline, AgentEpisode episode) { + String content = formatContent(timeline, episode); + return new FormattedSegment(content, metadata(timeline, episode)); + } + + private String formatContent(AgentTimelineContent timeline, AgentEpisode episode) { + var lines = new ArrayList(); + appendSentence(lines, "Goal", episode.goal()); + lines.add("Outcome: " + episode.outcome().wireValue()); + if (timeline.project() != null && hasText(timeline.project().name())) { + lines.add("Project: " + timeline.project().name()); + } + if (!episode.files().isEmpty()) { + lines.add("Files: " + String.join(", ", episode.files())); + } + if (!episode.commands().isEmpty()) { + lines.add("Commands:"); + episode.commandEvents().stream() + .filter(command -> hasText(command.command())) + .forEach(command -> lines.add("- " + formatCommand(command))); + } + List actions = actions(episode); + if (!actions.isEmpty()) { + lines.add("Actions:"); + actions.forEach(action -> lines.add("- " + action)); + } + lines.add("Evidence:"); + episode.events().forEach(event -> lines.add("- " + event.id() + ": " + evidence(event))); + return String.join("\n", lines); + } + + private Map metadata(AgentTimelineContent timeline, AgentEpisode episode) { + var metadata = new LinkedHashMap(); + metadata.put("segmentType", "agent_episode"); + metadata.put("sourceClient", timeline.sourceClient()); + metadata.put("sessionId", timeline.sessionId()); + metadata.put("timelineId", timeline.timelineId()); + metadata.put("episodeId", episode.id()); + metadata.put("phase", episode.phase()); + metadata.put("goal", episode.goal()); + metadata.put("outcome", episode.outcome().wireValue()); + AgentProject project = timeline.project(); + if (project != null) { + if (hasText(project.name())) { + metadata.put("projectName", project.name()); + } + if (hasText(project.rootPath())) { + metadata.put( + "projectRootHash", "sha256:" + HashUtils.sampledSha256(project.rootPath())); + } + if (project.git() != null && hasText(project.git().branch())) { + metadata.put("gitBranch", project.git().branch()); + } + } + metadata.put("files", episode.files()); + metadata.put("commands", episode.commands()); + metadata.put("toolNames", episode.toolNames()); + metadata.put("failureSignals", episode.failureSignals()); + metadata.put("eventIds", episode.eventIds()); + if (episode.startTime() != null) { + metadata.put("windowStart", episode.startTime()); + } + if (episode.endTime() != null) { + metadata.put("windowEnd", episode.endTime()); + } + metadata.putAll(episode.metadata()); + return Map.copyOf(metadata); + } + + private static String formatCommand(AgentCommand command) { + String status = + command.status() == null + ? AgentEventStatus.UNKNOWN.wireValue() + : command.status().wireValue(); + if (command.status() == AgentEventStatus.FAILED && hasText(command.output())) { + return command.command() + " -> " + status + ": " + concise(command.output()); + } + return command.command() + " -> " + status; + } + + private static List actions(AgentEpisode episode) { + var actions = new ArrayList(); + episode.fileReferences().stream() + .filter(file -> hasText(file.path())) + .forEach(file -> actions.add(operationLabel(file.operation()) + " " + file.path())); + return List.copyOf(actions); + } + + private static String evidence(AgentEvent event) { + var parts = new ArrayList(); + if (event.kind() != null) { + parts.add(event.kind().wireValue()); + } + if (hasText(event.command())) { + parts.add(event.command()); + } + if (hasText(event.path())) { + parts.add(event.path()); + } + if (event.status() != null) { + parts.add(event.status().wireValue()); + } + if (hasText(event.input())) { + parts.add(concise(event.input())); + } + if (hasText(event.output())) { + parts.add(concise(event.output())); + } else if (hasText(event.text())) { + parts.add(concise(event.text())); + } + return String.join(" ", parts); + } + + private static String operationLabel(String operation) { + if (!hasText(operation)) { + return "Touched"; + } + return switch (operation.toLowerCase(java.util.Locale.ROOT)) { + case "read" -> "Read"; + case "edit", "write", "patch", "modified" -> "Modified"; + default -> Character.toUpperCase(operation.charAt(0)) + operation.substring(1); + }; + } + + private static void appendSentence(List lines, String label, String value) { + if (!hasText(value)) { + return; + } + String trimmed = value.trim(); + if (!trimmed.endsWith(".") && !trimmed.endsWith("?") && !trimmed.endsWith("!")) { + trimmed += "."; + } + lines.add(label + ": " + trimmed); + } + + private static String concise(String value) { + if (value == null) { + return ""; + } + String normalized = value.replaceAll("\\s+", " ").trim(); + if (normalized.length() <= 180) { + return normalized; + } + return normalized.substring(0, 180); + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + public record FormattedSegment(String content, Map metadata) { + + public FormattedSegment { + metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + } + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentTimelineChunker.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentTimelineChunker.java new file mode 100644 index 00000000..d84a5fe1 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentTimelineChunker.java @@ -0,0 +1,84 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.chunk; + +import com.openmemind.ai.memory.core.extraction.rawdata.segment.CharBoundary; +import com.openmemind.ai.memory.core.extraction.rawdata.segment.Segment; +import com.openmemind.ai.memory.core.extraction.rawdata.segment.SegmentRuntimeContext; +import com.openmemind.ai.memory.plugin.rawdata.agent.config.AgentChunkingOptions; +import com.openmemind.ai.memory.plugin.rawdata.agent.config.AgentPrivacyOptions; +import com.openmemind.ai.memory.plugin.rawdata.agent.content.AgentTimelineContent; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEpisode; +import com.openmemind.ai.memory.plugin.rawdata.agent.privacy.AgentEventRedactor; +import java.util.List; + +/** + * Redacts, assembles, and formats agent timeline content into rawdata segments. + */ +public final class AgentTimelineChunker { + + private final AgentEpisodeAssembler assembler; + private final AgentSegmentFormatter formatter; + private final AgentEventRedactor redactor; + + public AgentTimelineChunker() { + this(AgentChunkingOptions.defaults(), new AgentPrivacyOptions()); + } + + public AgentTimelineChunker( + AgentChunkingOptions chunkingOptions, AgentPrivacyOptions privacyOptions) { + this( + new AgentEpisodeAssembler(chunkingOptions), + new AgentSegmentFormatter(), + new AgentEventRedactor(privacyOptions)); + } + + AgentTimelineChunker( + AgentEpisodeAssembler assembler, + AgentSegmentFormatter formatter, + AgentEventRedactor redactor) { + this.assembler = assembler; + this.formatter = formatter; + this.redactor = redactor; + } + + public List chunk(AgentTimelineContent content) { + if (content == null || content.events().isEmpty()) { + return List.of(); + } + AgentTimelineContent redactedContent = + new AgentTimelineContent( + content.sourceClient(), + content.sourceVersion(), + content.sessionId(), + content.timelineId(), + content.project(), + content.events().stream().map(redactor::redact).toList(), + content.metadata()); + return assembler.assemble(redactedContent).stream() + .map(episode -> toSegment(redactedContent, episode)) + .toList(); + } + + private Segment toSegment(AgentTimelineContent content, AgentEpisode episode) { + AgentSegmentFormatter.FormattedSegment formatted = formatter.format(content, episode); + return new Segment( + formatted.content(), + null, + new CharBoundary(0, formatted.content().length()), + formatted.metadata(), + new SegmentRuntimeContext( + episode.startTime(), episode.endTime(), null, content.sourceClient())); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/config/AgentChunkingOptions.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/config/AgentChunkingOptions.java new file mode 100644 index 00000000..da8a4c6e --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/config/AgentChunkingOptions.java @@ -0,0 +1,39 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.config; + +import java.time.Duration; + +/** + * Chunking controls for agent timelines. + */ +public record AgentChunkingOptions( + int targetEpisodeTokens, int hardMaxTokens, int maxEventsPerEpisode, Duration maxEventGap) { + + public AgentChunkingOptions { + if (targetEpisodeTokens <= 0 || hardMaxTokens < targetEpisodeTokens) { + throw new IllegalArgumentException("invalid agent chunking token limits"); + } + if (maxEventsPerEpisode <= 0) { + throw new IllegalArgumentException("maxEventsPerEpisode must be positive"); + } + if (maxEventGap == null || maxEventGap.isZero() || maxEventGap.isNegative()) { + throw new IllegalArgumentException("maxEventGap must be positive"); + } + } + + public static AgentChunkingOptions defaults() { + return new AgentChunkingOptions(2_000, 4_000, 80, Duration.ofMinutes(30)); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentCommand.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentCommand.java new file mode 100644 index 00000000..c5bc2625 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentCommand.java @@ -0,0 +1,35 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.model; + +import java.util.List; + +/** + * Command evidence aggregated from an agent episode. + */ +public record AgentCommand( + String command, + AgentEventStatus status, + String output, + Integer exitCode, + Integer seq, + String eventId) { + + public static List commandTexts(List commands) { + if (commands == null || commands.isEmpty()) { + return List.of(); + } + return commands.stream().map(AgentCommand::command).distinct().toList(); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEpisode.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEpisode.java new file mode 100644 index 00000000..25fbf4a5 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEpisode.java @@ -0,0 +1,54 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.model; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * Deterministic evidence segment derived from an agent timeline. + */ +public record AgentEpisode( + String id, + String goal, + AgentOutcome outcome, + String phase, + List events, + List eventIds, + List files, + List fileReferences, + List commands, + List commandEvents, + List toolNames, + List toolCalls, + List failureSignals, + Instant startTime, + Instant endTime, + Map metadata) { + + public AgentEpisode { + phase = phase == null || phase.isBlank() ? "full" : phase; + events = events == null ? List.of() : List.copyOf(events); + eventIds = eventIds == null ? List.of() : List.copyOf(eventIds); + files = files == null ? List.of() : List.copyOf(files); + fileReferences = fileReferences == null ? List.of() : List.copyOf(fileReferences); + commands = commands == null ? List.of() : List.copyOf(commands); + commandEvents = commandEvents == null ? List.of() : List.copyOf(commandEvents); + toolNames = toolNames == null ? List.of() : List.copyOf(toolNames); + toolCalls = toolCalls == null ? List.of() : List.copyOf(toolCalls); + failureSignals = failureSignals == null ? List.of() : List.copyOf(failureSignals); + metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentFileReference.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentFileReference.java new file mode 100644 index 00000000..0d58499d --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentFileReference.java @@ -0,0 +1,19 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.model; + +/** + * File evidence aggregated from an agent episode. + */ +public record AgentFileReference(String path, String operation, Integer seq, String eventId) {} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentOutcome.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentOutcome.java new file mode 100644 index 00000000..fa2b4c5d --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentOutcome.java @@ -0,0 +1,42 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.Locale; + +/** + * Episode outcome. + */ +public enum AgentOutcome { + SUCCESS, + FAILED, + PARTIAL_SUCCESS, + CANCELLED, + UNKNOWN; + + @JsonCreator + public static AgentOutcome fromWireValue(String value) { + if (value == null || value.isBlank()) { + return null; + } + return AgentOutcome.valueOf(value.trim().replace('-', '_').toUpperCase(Locale.ROOT)); + } + + @JsonValue + public String wireValue() { + return name().toLowerCase(Locale.ROOT); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentToolCall.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentToolCall.java new file mode 100644 index 00000000..bef77afe --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentToolCall.java @@ -0,0 +1,20 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.model; + +/** + * Tool evidence aggregated from an agent episode. + */ +public record AgentToolCall( + String toolName, AgentEventStatus status, Integer seq, String eventId) {} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGeneratorTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGeneratorTest.java new file mode 100644 index 00000000..581667d5 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGeneratorTest.java @@ -0,0 +1,46 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.caption; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class AgentCaptionGeneratorTest { + + @Test + void shouldBuildDeterministicAgentEpisodeCaptionFromMetadata() { + String caption = + new AgentCaptionGenerator() + .generate( + "content", + Map.of( + "goal", + "Fix payment tests", + "outcome", + "success", + "files", + List.of("src/payment/calc.ts"), + "commands", + List.of("npm test payment"))) + .block(); + + assertThat(caption) + .isEqualTo( + "Agent episode: Fix payment tests -> success " + + "(src/payment/calc.ts; npm test payment)"); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java new file mode 100644 index 00000000..d613c581 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java @@ -0,0 +1,235 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.chunk; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.openmemind.ai.memory.plugin.rawdata.agent.config.AgentChunkingOptions; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEpisode; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEvent; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventKind; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventStatus; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentOutcome; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class AgentEpisodeAssemblerTest { + + @Test + void shouldAssembleSuccessfulPaymentEpisodeWithStableEvidence() { + var timeline = + AgentEpisodeTestSupport.paymentTimeline(AgentEpisodeTestSupport.paymentEvents()); + + List episodes = new AgentEpisodeAssembler().assemble(timeline); + + assertThat(episodes).hasSize(1); + AgentEpisode episode = episodes.getFirst(); + assertThat(episode.goal()).isEqualTo("Fix payment tests"); + assertThat(episode.outcome()).isEqualTo(AgentOutcome.SUCCESS); + assertThat(episode.eventIds()).containsExactly("e1", "e2", "e3", "e4", "e5"); + assertThat(episode.files()).containsExactly("src/payment/calc.ts"); + assertThat(episode.commands()).containsExactly("npm test payment"); + assertThat(episode.failureSignals()).contains("rounding mismatch"); + assertThat(episode.id()) + .isEqualTo(new AgentEpisodeAssembler().assemble(timeline).getFirst().id()); + } + + @Test + void shouldClosePreviousEpisodeWhenNewUserPromptAppears() { + var events = new ArrayList<>(AgentEpisodeTestSupport.paymentEvents()); + events.add( + AgentEpisodeTestSupport.event( + "e6", + 6, + AgentEventKind.USER_PROMPT, + "2026-05-24T10:05:00Z", + "Fix auth tests", + null, + null, + null, + null, + null, + null, + null)); + events.add( + AgentEpisodeTestSupport.event( + "e7", + 7, + AgentEventKind.STOP, + "2026-05-24T10:06:00Z", + null, + null, + null, + AgentEventStatus.SUCCESS, + null, + null, + null, + null)); + + List episodes = + new AgentEpisodeAssembler() + .assemble(AgentEpisodeTestSupport.paymentTimeline(events)); + + assertThat(episodes) + .extracting(AgentEpisode::goal) + .containsExactly("Fix payment tests", "Fix auth tests"); + assertThat(episodes.getFirst().eventIds()).containsExactly("e1", "e2", "e3", "e4", "e5"); + assertThat(episodes.get(1).eventIds()).containsExactly("e6", "e7"); + } + + @Test + void shouldSplitEpisodesOnThirtyOneMinuteGap() { + List events = + List.of( + AgentEpisodeTestSupport.event( + "e1", + 1, + AgentEventKind.USER_PROMPT, + "2026-05-24T10:00:00Z", + "Fix payment tests", + null, + null, + null, + null, + null, + null, + null), + AgentEpisodeTestSupport.event( + "e2", + 2, + AgentEventKind.COMMAND, + "2026-05-24T10:01:00Z", + null, + "Bash", + "rounding mismatch", + AgentEventStatus.FAILED, + null, + null, + "npm test payment", + 1), + AgentEpisodeTestSupport.event( + "e3", + 3, + AgentEventKind.COMMAND, + "2026-05-24T10:32:01Z", + null, + "Bash", + "passed", + AgentEventStatus.SUCCESS, + null, + null, + "npm test payment", + 0)); + + List episodes = + new AgentEpisodeAssembler() + .assemble(AgentEpisodeTestSupport.paymentTimeline(events)); + + assertThat(episodes).hasSize(2); + assertThat(episodes.getFirst().eventIds()).containsExactly("e1", "e2"); + assertThat(episodes.get(1).eventIds()).containsExactly("e3"); + } + + @Test + void shouldSplitEpisodesWhenEventCountExceedsMax() { + AgentChunkingOptions options = + new AgentChunkingOptions(2_000, 4_000, 2, Duration.ofMinutes(30)); + List events = AgentEpisodeTestSupport.paymentEvents(); + + List episodes = + new AgentEpisodeAssembler(options) + .assemble(AgentEpisodeTestSupport.paymentTimeline(events)); + + assertThat(episodes).hasSize(3); + assertThat(episodes) + .extracting(AgentEpisode::eventIds) + .containsExactly(List.of("e1", "e2"), List.of("e3", "e4"), List.of("e5")); + } + + @Test + void shouldSplitOversizedEpisodeIntoPhases() { + AgentChunkingOptions options = new AgentChunkingOptions(20, 40, 80, Duration.ofMinutes(30)); + + List episodes = + new AgentEpisodeAssembler(options) + .assemble( + AgentEpisodeTestSupport.paymentTimeline( + AgentEpisodeTestSupport.paymentEvents())); + + assertThat(episodes) + .extracting(AgentEpisode::phase) + .containsExactly("investigation", "implementation", "validation", "handoff"); + assertThat(episodes) + .allSatisfy(episode -> assertThat(episode.goal()).isEqualTo("Fix payment tests")); + assertThat(episodes) + .allSatisfy( + episode -> + assertThat(episode.metadata()).containsEntry("phaseSplit", true)); + } + + @Test + void shouldSplitEpisodesWhenTaskMetadataChanges() { + List events = + List.of( + eventWithMetadata( + "e1", 1, AgentEventKind.USER_PROMPT, "Fix payment tests", "task-a"), + eventWithMetadata("e2", 2, AgentEventKind.COMMAND, null, "task-a"), + eventWithMetadata("e3", 3, AgentEventKind.COMMAND, null, "task-b")); + + List episodes = + new AgentEpisodeAssembler() + .assemble(AgentEpisodeTestSupport.paymentTimeline(events)); + + assertThat(episodes).hasSize(2); + assertThat(episodes.getFirst().eventIds()).containsExactly("e1", "e2"); + assertThat(episodes.get(1).eventIds()).containsExactly("e3"); + } + + private static AgentEvent eventWithMetadata( + String id, int seq, AgentEventKind kind, String text, String taskId) { + AgentEvent base = + AgentEpisodeTestSupport.event( + id, + seq, + kind, + "2026-05-24T10:0" + seq + ":00Z", + text, + "Bash", + null, + AgentEventStatus.SUCCESS, + null, + null, + "npm test payment", + 0); + return new AgentEvent( + base.id(), + base.seq(), + base.kind(), + base.occurredAt(), + base.text(), + base.toolName(), + base.input(), + base.output(), + base.status(), + base.durationMs(), + base.path(), + base.operation(), + base.command(), + base.exitCode(), + Map.of("taskId", taskId)); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeTestSupport.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeTestSupport.java new file mode 100644 index 00000000..bc48bdb5 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeTestSupport.java @@ -0,0 +1,138 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.chunk; + +import com.openmemind.ai.memory.plugin.rawdata.agent.content.AgentTimelineContent; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEvent; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventKind; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventStatus; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentProject; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +final class AgentEpisodeTestSupport { + + private AgentEpisodeTestSupport() {} + + static AgentTimelineContent paymentTimeline(List events) { + return new AgentTimelineContent( + "codex", + "1.0", + "session-123", + "timeline-123", + new AgentProject("payments-api", "/Users/alice/work/payments-api", null, Map.of()), + events); + } + + static List paymentEvents() { + return List.of( + event( + "e1", + 1, + AgentEventKind.USER_PROMPT, + "2026-05-24T10:00:00Z", + "Fix payment tests", + null, + null, + null, + null, + null, + null, + null), + event( + "e2", + 2, + AgentEventKind.COMMAND, + "2026-05-24T10:01:00Z", + null, + "Bash", + "rounding mismatch", + AgentEventStatus.FAILED, + null, + null, + "npm test payment", + 1), + event( + "e3", + 3, + AgentEventKind.FILE_EDIT, + "2026-05-24T10:02:00Z", + null, + "Edit", + "changed rounding logic", + AgentEventStatus.SUCCESS, + "src/payment/calc.ts", + "edit", + null, + null), + event( + "e4", + 4, + AgentEventKind.COMMAND, + "2026-05-24T10:03:00Z", + null, + "Bash", + "passed", + AgentEventStatus.SUCCESS, + null, + null, + "npm test payment", + 0), + event( + "e5", + 5, + AgentEventKind.STOP, + "2026-05-24T10:04:00Z", + "done", + null, + null, + AgentEventStatus.SUCCESS, + null, + null, + null, + null)); + } + + static AgentEvent event( + String id, + int seq, + AgentEventKind kind, + String occurredAt, + String text, + String toolName, + String output, + AgentEventStatus status, + String path, + String operation, + String command, + Integer exitCode) { + return new AgentEvent( + id, + seq, + kind, + Instant.parse(occurredAt), + text, + toolName, + null, + output, + status, + 10L, + path, + operation, + command, + exitCode, + Map.of()); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java new file mode 100644 index 00000000..9307233a --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java @@ -0,0 +1,69 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.chunk; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEpisode; +import org.junit.jupiter.api.Test; + +class AgentSegmentFormatterTest { + + @Test + void shouldFormatEpisodeTextAndMetadataDeterministically() { + var timeline = + AgentEpisodeTestSupport.paymentTimeline(AgentEpisodeTestSupport.paymentEvents()); + AgentEpisode episode = new AgentEpisodeAssembler().assemble(timeline).getFirst(); + + AgentSegmentFormatter.FormattedSegment formatted = + new AgentSegmentFormatter().format(timeline, episode); + + assertThat(formatted.content()) + .contains( + "Goal: Fix payment tests.", + "Outcome: success", + "Project: payments-api", + "Files: src/payment/calc.ts", + "Commands:", + "- npm test payment -> failed: rounding mismatch", + "- npm test payment -> success", + "Evidence:", + "- e2:", + "- e4:"); + assertThat(formatted.metadata()) + .containsEntry("segmentType", "agent_episode") + .containsEntry("episodeId", episode.id()) + .containsEntry("phase", "full") + .containsEntry("sourceClient", "codex") + .containsEntry("sessionId", "session-123") + .containsEntry("timelineId", "timeline-123") + .containsEntry("projectName", "payments-api") + .containsEntry("outcome", "success"); + assertThat(formatted.metadata().get("files")) + .asList() + .containsExactly("src/payment/calc.ts"); + assertThat(formatted.metadata().get("commands")) + .asList() + .containsExactly("npm test payment"); + assertThat(formatted.metadata().get("toolNames")).asList().containsExactly("Bash", "Edit"); + assertThat(formatted.metadata().get("failureSignals")) + .asList() + .contains("rounding mismatch"); + assertThat(formatted.metadata().get("eventIds")) + .asList() + .containsExactly("e1", "e2", "e3", "e4", "e5"); + assertThat(formatted.metadata()).doesNotContainKey("projectRootRaw"); + assertThat(formatted.metadata()).containsKey("projectRootHash"); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentTimelineChunkerTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentTimelineChunkerTest.java new file mode 100644 index 00000000..db7e6d4d --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentTimelineChunkerTest.java @@ -0,0 +1,70 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.chunk; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.openmemind.ai.memory.core.extraction.rawdata.segment.CharBoundary; +import com.openmemind.ai.memory.core.extraction.rawdata.segment.Segment; +import com.openmemind.ai.memory.plugin.rawdata.agent.content.AgentTimelineContent; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEvent; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventKind; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventStatus; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class AgentTimelineChunkerTest { + + @Test + void shouldRedactAssembleAndFormatAgentTimelineSegments() { + AgentTimelineContent timeline = + AgentEpisodeTestSupport.paymentTimeline( + List.of( + AgentEpisodeTestSupport.paymentEvents().get(0), + new AgentEvent( + "e2", + 2, + AgentEventKind.COMMAND, + Instant.parse("2026-05-24T10:01:00Z"), + null, + "Bash", + "Authorization: Bearer abc.def.ghi", + "rounding mismatch", + AgentEventStatus.FAILED, + 10L, + null, + null, + "npm test payment", + 1, + Map.of()), + AgentEpisodeTestSupport.paymentEvents().get(2), + AgentEpisodeTestSupport.paymentEvents().get(3), + AgentEpisodeTestSupport.paymentEvents().get(4))); + + List segments = new AgentTimelineChunker().chunk(timeline); + + assertThat(segments).hasSize(1); + Segment segment = segments.getFirst(); + assertThat(segment.content()).contains("[REDACTED:bearer_token]"); + assertThat(segment.metadata()).containsEntry("segmentType", "agent_episode"); + assertThat(segment.boundary()).isEqualTo(new CharBoundary(0, segment.content().length())); + assertThat(segment.runtimeContext().startTime()) + .isEqualTo(Instant.parse("2026-05-24T10:00:00Z")); + assertThat(segment.runtimeContext().observedAt()) + .isEqualTo(Instant.parse("2026-05-24T10:04:00Z")); + assertThat(segment.runtimeContext().sourceClient()).isEqualTo("codex"); + } +} From 29708d3f885d352682556c7982cdb24ba7aa6153 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 25 May 2026 10:46:43 +0800 Subject: [PATCH 08/54] feat(rawdata-agent): register agent rawdata processor --- .../agent/config/AgentExtractionOptions.java | 37 ++++++++ .../agent/config/AgentRawDataOptions.java | 36 +++++++ .../item/AgentItemExtractionStrategy.java | 71 ++++++++++++++ .../agent/plugin/AgentRawDataPlugin.java | 64 +++++++++++++ .../AgentTimelineContentProcessor.java | 94 +++++++++++++++++++ .../agent/plugin/AgentRawDataPluginTest.java | 70 ++++++++++++++ .../AgentTimelineContentProcessorTest.java | 45 +++++++++ 7 files changed, 417 insertions(+) create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/config/AgentExtractionOptions.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/config/AgentRawDataOptions.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/plugin/AgentRawDataPlugin.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/processor/AgentTimelineContentProcessor.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/plugin/AgentRawDataPluginTest.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/processor/AgentTimelineContentProcessorTest.java diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/config/AgentExtractionOptions.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/config/AgentExtractionOptions.java new file mode 100644 index 00000000..1b0c321d --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/config/AgentExtractionOptions.java @@ -0,0 +1,37 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.config; + +/** + * Item extraction controls for agent episode segments. + */ +public record AgentExtractionOptions( + boolean extractTool, + boolean extractResolution, + boolean extractPlaybook, + boolean extractDirective, + boolean extractOnEveryTool, + int minEventsForExtraction, + int minEventsForPlaybook, + boolean requireSuccessForPlaybook) { + + public AgentExtractionOptions { + minEventsForExtraction = Math.max(0, minEventsForExtraction); + minEventsForPlaybook = Math.max(0, minEventsForPlaybook); + } + + public static AgentExtractionOptions defaults() { + return new AgentExtractionOptions(true, true, true, true, false, 3, 5, true); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/config/AgentRawDataOptions.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/config/AgentRawDataOptions.java new file mode 100644 index 00000000..8789c61b --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/config/AgentRawDataOptions.java @@ -0,0 +1,36 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.config; + +/** + * Aggregated rawdata-agent plugin options. + */ +public record AgentRawDataOptions( + AgentChunkingOptions chunking, + AgentPrivacyOptions privacy, + AgentExtractionOptions extraction) { + + public AgentRawDataOptions { + chunking = chunking == null ? AgentChunkingOptions.defaults() : chunking; + privacy = privacy == null ? new AgentPrivacyOptions() : privacy; + extraction = extraction == null ? AgentExtractionOptions.defaults() : extraction; + } + + public static AgentRawDataOptions defaults() { + return new AgentRawDataOptions( + AgentChunkingOptions.defaults(), + new AgentPrivacyOptions(), + AgentExtractionOptions.defaults()); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java new file mode 100644 index 00000000..e10c784a --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java @@ -0,0 +1,71 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.item; + +import com.openmemind.ai.memory.core.data.MemoryInsightType; +import com.openmemind.ai.memory.core.extraction.item.ItemExtractionConfig; +import com.openmemind.ai.memory.core.extraction.item.ItemExtractionStrategy; +import com.openmemind.ai.memory.core.extraction.item.support.ExtractedMemoryEntry; +import com.openmemind.ai.memory.core.extraction.rawdata.ParsedSegment; +import com.openmemind.ai.memory.core.llm.StructuredChatClient; +import com.openmemind.ai.memory.core.prompt.PromptRegistry; +import com.openmemind.ai.memory.plugin.rawdata.agent.config.AgentExtractionOptions; +import java.util.List; +import reactor.core.publisher.Mono; + +/** + * Agent-specific item extraction strategy. + * + *

Task 9 adds deterministic TOOL/RESOLUTION extraction and Task 10 adds LLM + * PLAYBOOK/DIRECTIVE extraction. + */ +public class AgentItemExtractionStrategy implements ItemExtractionStrategy { + + private final StructuredChatClient chatClient; + private final PromptRegistry promptRegistry; + private final AgentExtractionOptions options; + + public AgentItemExtractionStrategy() { + this(null, PromptRegistry.EMPTY, AgentExtractionOptions.defaults()); + } + + public AgentItemExtractionStrategy( + StructuredChatClient chatClient, + PromptRegistry promptRegistry, + AgentExtractionOptions options) { + this.chatClient = chatClient; + this.promptRegistry = promptRegistry == null ? PromptRegistry.EMPTY : promptRegistry; + this.options = options == null ? AgentExtractionOptions.defaults() : options; + } + + @Override + public Mono> extract( + List segments, + List insightTypes, + ItemExtractionConfig config) { + return Mono.just(List.of()); + } + + public StructuredChatClient chatClient() { + return chatClient; + } + + public PromptRegistry promptRegistry() { + return promptRegistry; + } + + public AgentExtractionOptions options() { + return options; + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/plugin/AgentRawDataPlugin.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/plugin/AgentRawDataPlugin.java new file mode 100644 index 00000000..dad4aca2 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/plugin/AgentRawDataPlugin.java @@ -0,0 +1,64 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.plugin; + +import com.openmemind.ai.memory.core.extraction.rawdata.RawContentProcessor; +import com.openmemind.ai.memory.core.extraction.rawdata.RawContentTypeRegistrar; +import com.openmemind.ai.memory.core.plugin.RawDataPlugin; +import com.openmemind.ai.memory.core.plugin.RawDataPluginContext; +import com.openmemind.ai.memory.plugin.rawdata.agent.AgentRawContentTypeRegistrar; +import com.openmemind.ai.memory.plugin.rawdata.agent.caption.AgentCaptionGenerator; +import com.openmemind.ai.memory.plugin.rawdata.agent.chunk.AgentTimelineChunker; +import com.openmemind.ai.memory.plugin.rawdata.agent.config.AgentRawDataOptions; +import com.openmemind.ai.memory.plugin.rawdata.agent.item.AgentItemExtractionStrategy; +import com.openmemind.ai.memory.plugin.rawdata.agent.processor.AgentTimelineContentProcessor; +import java.util.List; + +/** + * RawData plugin contribution for coding-agent timelines. + */ +public final class AgentRawDataPlugin implements RawDataPlugin { + + private final AgentRawDataOptions options; + + public AgentRawDataPlugin() { + this(AgentRawDataOptions.defaults()); + } + + public AgentRawDataPlugin(AgentRawDataOptions options) { + this.options = options == null ? AgentRawDataOptions.defaults() : options; + } + + @Override + public String pluginId() { + return "rawdata-agent"; + } + + @Override + public List> processors(RawDataPluginContext context) { + return List.of( + new AgentTimelineContentProcessor( + new AgentTimelineChunker(options.chunking(), options.privacy()), + new AgentCaptionGenerator(), + new AgentItemExtractionStrategy( + context.chatClientRegistry().defaultClient(), + context.promptRegistry(), + options.extraction()))); + } + + @Override + public List typeRegistrars() { + return List.of(new AgentRawContentTypeRegistrar()); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/processor/AgentTimelineContentProcessor.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/processor/AgentTimelineContentProcessor.java new file mode 100644 index 00000000..c9461bc0 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/processor/AgentTimelineContentProcessor.java @@ -0,0 +1,94 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.processor; + +import com.openmemind.ai.memory.core.data.enums.MemoryCategory; +import com.openmemind.ai.memory.core.extraction.item.ItemExtractionStrategy; +import com.openmemind.ai.memory.core.extraction.rawdata.RawContentProcessor; +import com.openmemind.ai.memory.core.extraction.rawdata.caption.CaptionGenerator; +import com.openmemind.ai.memory.core.extraction.rawdata.segment.Segment; +import com.openmemind.ai.memory.plugin.rawdata.agent.caption.AgentCaptionGenerator; +import com.openmemind.ai.memory.plugin.rawdata.agent.chunk.AgentTimelineChunker; +import com.openmemind.ai.memory.plugin.rawdata.agent.content.AgentTimelineContent; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import reactor.core.publisher.Mono; + +/** + * RawData processor for coding-agent timelines. + */ +public final class AgentTimelineContentProcessor + implements RawContentProcessor { + + private final AgentTimelineChunker chunker; + private final CaptionGenerator captionGenerator; + private final ItemExtractionStrategy itemExtractionStrategy; + + public AgentTimelineContentProcessor(ItemExtractionStrategy itemExtractionStrategy) { + this(new AgentTimelineChunker(), new AgentCaptionGenerator(), itemExtractionStrategy); + } + + public AgentTimelineContentProcessor( + AgentTimelineChunker chunker, + CaptionGenerator captionGenerator, + ItemExtractionStrategy itemExtractionStrategy) { + this.chunker = Objects.requireNonNull(chunker, "chunker must not be null"); + this.captionGenerator = + Objects.requireNonNull(captionGenerator, "captionGenerator must not be null"); + this.itemExtractionStrategy = + Objects.requireNonNull( + itemExtractionStrategy, "itemExtractionStrategy must not be null"); + } + + @Override + public Class contentClass() { + return AgentTimelineContent.class; + } + + @Override + public String contentType() { + return AgentTimelineContent.TYPE; + } + + @Override + public Mono> chunk(AgentTimelineContent content) { + return Mono.just(chunker.chunk(content)); + } + + @Override + public CaptionGenerator captionGenerator() { + return captionGenerator; + } + + @Override + public ItemExtractionStrategy itemExtractionStrategy() { + return itemExtractionStrategy; + } + + @Override + public Set allowedCategories() { + return MemoryCategory.agentCategories(); + } + + @Override + public boolean usesSourceIdentity() { + return true; + } + + @Override + public boolean supportsInsight() { + return true; + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/plugin/AgentRawDataPluginTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/plugin/AgentRawDataPluginTest.java new file mode 100644 index 00000000..ec4695a9 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/plugin/AgentRawDataPluginTest.java @@ -0,0 +1,70 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.plugin; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.openmemind.ai.memory.core.builder.MemoryBuildOptions; +import com.openmemind.ai.memory.core.llm.ChatClientRegistry; +import com.openmemind.ai.memory.core.llm.ChatMessage; +import com.openmemind.ai.memory.core.llm.StructuredChatClient; +import com.openmemind.ai.memory.core.plugin.RawDataPlugin; +import com.openmemind.ai.memory.core.plugin.RawDataPluginContext; +import com.openmemind.ai.memory.core.prompt.PromptRegistry; +import com.openmemind.ai.memory.plugin.rawdata.agent.AgentRawContentTypeRegistrar; +import com.openmemind.ai.memory.plugin.rawdata.agent.processor.AgentTimelineContentProcessor; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +class AgentRawDataPluginTest { + + @Test + void pluginShouldExposeStableIdSubtypeRegistrarAndProcessor() { + RawDataPlugin plugin = new AgentRawDataPlugin(); + + assertThat(plugin.pluginId()).isEqualTo("rawdata-agent"); + assertThat(plugin.typeRegistrars()) + .singleElement() + .isInstanceOf(AgentRawContentTypeRegistrar.class); + assertThat(plugin.typeRegistrars()) + .extracting(registrar -> registrar.subtypes()) + .anySatisfy(map -> assertThat(map).containsKey("agent_timeline")); + assertThat(plugin.processors(pluginContext())) + .singleElement() + .isInstanceOf(AgentTimelineContentProcessor.class); + } + + private static RawDataPluginContext pluginContext() { + return new RawDataPluginContext( + new ChatClientRegistry(noopClient(), Map.of()), + PromptRegistry.EMPTY, + MemoryBuildOptions.defaults()); + } + + private static StructuredChatClient noopClient() { + return new StructuredChatClient() { + @Override + public Mono call(List messages) { + return Mono.error(new UnsupportedOperationException("not used by this test")); + } + + @Override + public Mono call(List messages, Class responseType) { + return Mono.error(new UnsupportedOperationException("not used by this test")); + } + }; + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/processor/AgentTimelineContentProcessorTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/processor/AgentTimelineContentProcessorTest.java new file mode 100644 index 00000000..8c38f3ce --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/processor/AgentTimelineContentProcessorTest.java @@ -0,0 +1,45 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.processor; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.openmemind.ai.memory.core.data.enums.MemoryCategory; +import com.openmemind.ai.memory.plugin.rawdata.agent.caption.AgentCaptionGenerator; +import com.openmemind.ai.memory.plugin.rawdata.agent.chunk.AgentTimelineChunker; +import com.openmemind.ai.memory.plugin.rawdata.agent.content.AgentTimelineContent; +import com.openmemind.ai.memory.plugin.rawdata.agent.item.AgentItemExtractionStrategy; +import org.junit.jupiter.api.Test; + +class AgentTimelineContentProcessorTest { + + @Test + void shouldExposeAgentTimelineProcessorContract() { + AgentTimelineContentProcessor processor = + new AgentTimelineContentProcessor( + new AgentTimelineChunker(), + new AgentCaptionGenerator(), + new AgentItemExtractionStrategy()); + + assertThat(processor.contentClass()).isEqualTo(AgentTimelineContent.class); + assertThat(processor.contentType()).isEqualTo(AgentTimelineContent.TYPE); + assertThat(processor.allowedCategories()) + .containsExactlyInAnyOrderElementsOf(MemoryCategory.agentCategories()); + assertThat(processor.usesSourceIdentity()).isTrue(); + assertThat(processor.supportsInsight()).isTrue(); + assertThat(processor.itemExtractionStrategy()) + .isInstanceOf(AgentItemExtractionStrategy.class); + assertThat(processor.captionGenerator()).isInstanceOf(AgentCaptionGenerator.class); + } +} From 4822a3f36e1c796a7121ae7ea5b950c214989fe5 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 25 May 2026 10:55:48 +0800 Subject: [PATCH 09/54] feat(rawdata-agent): extract deterministic agent memories --- .../item/AgentItemExtractionStrategy.java | 23 +- .../agent/item/AgentMemoryItemFactory.java | 411 ++++++++++++++++++ .../item/AgentItemExtractionStrategyTest.java | 238 ++++++++++ 3 files changed, 671 insertions(+), 1 deletion(-) create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java index e10c784a..5dbdbd41 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java @@ -22,6 +22,7 @@ import com.openmemind.ai.memory.core.prompt.PromptRegistry; import com.openmemind.ai.memory.plugin.rawdata.agent.config.AgentExtractionOptions; import java.util.List; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; /** @@ -35,6 +36,7 @@ public class AgentItemExtractionStrategy implements ItemExtractionStrategy { private final StructuredChatClient chatClient; private final PromptRegistry promptRegistry; private final AgentExtractionOptions options; + private final AgentMemoryItemFactory memoryItemFactory; public AgentItemExtractionStrategy() { this(null, PromptRegistry.EMPTY, AgentExtractionOptions.defaults()); @@ -44,9 +46,19 @@ public AgentItemExtractionStrategy( StructuredChatClient chatClient, PromptRegistry promptRegistry, AgentExtractionOptions options) { + this(chatClient, promptRegistry, options, new AgentMemoryItemFactory()); + } + + public AgentItemExtractionStrategy( + StructuredChatClient chatClient, + PromptRegistry promptRegistry, + AgentExtractionOptions options, + AgentMemoryItemFactory memoryItemFactory) { this.chatClient = chatClient; this.promptRegistry = promptRegistry == null ? PromptRegistry.EMPTY : promptRegistry; this.options = options == null ? AgentExtractionOptions.defaults() : options; + this.memoryItemFactory = + memoryItemFactory == null ? new AgentMemoryItemFactory() : memoryItemFactory; } @Override @@ -54,7 +66,12 @@ public Mono> extract( List segments, List insightTypes, ItemExtractionConfig config) { - return Mono.just(List.of()); + if (segments == null || segments.isEmpty()) { + return Mono.just(List.of()); + } + return Flux.fromIterable(segments) + .flatMapIterable(memoryItemFactory::deterministicEntries) + .collectList(); } public StructuredChatClient chatClient() { @@ -68,4 +85,8 @@ public PromptRegistry promptRegistry() { public AgentExtractionOptions options() { return options; } + + public AgentMemoryItemFactory memoryItemFactory() { + return memoryItemFactory; + } } diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java new file mode 100644 index 00000000..f2301c96 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java @@ -0,0 +1,411 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.item; + +import com.openmemind.ai.memory.core.data.enums.MemoryCategory; +import com.openmemind.ai.memory.core.data.enums.MemoryItemType; +import com.openmemind.ai.memory.core.extraction.item.support.ExtractedGraphHints; +import com.openmemind.ai.memory.core.extraction.item.support.ExtractedMemoryEntry; +import com.openmemind.ai.memory.core.extraction.rawdata.ParsedSegment; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; + +/** + * Builds deterministic memory items from agent episode metadata. + */ +public final class AgentMemoryItemFactory { + + public List deterministicEntries(ParsedSegment segment) { + if (!isAgentEpisode(segment)) { + return List.of(); + } + EpisodeMetadata metadata = EpisodeMetadata.from(segment); + var entries = new java.util.ArrayList(); + buildTool(segment, metadata).ifPresent(entries::add); + buildResolution(segment, metadata).ifPresent(entries::add); + return List.copyOf(entries); + } + + private java.util.Optional buildTool( + ParsedSegment segment, EpisodeMetadata episode) { + if (episode.commands().isEmpty() && episode.toolNames().isEmpty()) { + return java.util.Optional.empty(); + } + + String command = episode.commands().isEmpty() ? null : episode.commands().getFirst(); + String toolName = episode.toolNames().isEmpty() ? null : episode.toolNames().getFirst(); + int successCount = episode.successCount(command); + int failCount = episode.failCount(command); + String content = toolContent(episode, toolName, command, successCount, failCount); + + var metadata = baseMetadata(episode); + if (toolName != null) { + metadata.put("toolName", toolName); + } + if (command != null) { + metadata.put("command", command); + } + metadata.put("successCount", successCount); + metadata.put("failCount", failCount); + metadata.put("evidenceEventIds", episode.eventIds()); + + return java.util.Optional.of( + entry( + content, + segment, + List.of("tools"), + metadata, + MemoryCategory.TOOL.categoryName(), + graphHints(episode, true, false))); + } + + private java.util.Optional buildResolution( + ParsedSegment segment, EpisodeMetadata episode) { + if (episode.failureSignals().isEmpty() || !episode.resolvedOutcome()) { + return java.util.Optional.empty(); + } + Validation validation = episode.validation(); + if (validation == null) { + return java.util.Optional.empty(); + } + + String failure = episode.failureSignals().getFirst(); + String files = + episode.files().isEmpty() + ? "the touched files" + : String.join(", ", episode.files()); + String commands = + validation.command() == null + ? String.join(", ", episode.commands()) + : validation.command(); + String content = + "%s was resolved in %s and validated with %s.".formatted(failure, files, commands); + + var metadata = baseMetadata(episode); + metadata.put("problem", failure); + metadata.put("outcome", episode.outcome()); + metadata.put("validatedBy", commands); + metadata.put("evidenceEventIds", validation.evidenceEventIds()); + + return java.util.Optional.of( + entry( + content, + segment, + List.of("resolutions"), + metadata, + MemoryCategory.RESOLUTION.categoryName(), + graphHints(episode, true, true))); + } + + private ExtractedMemoryEntry entry( + String content, + ParsedSegment segment, + List insightTypes, + Map metadata, + String category, + ExtractedGraphHints graphHints) { + return new ExtractedMemoryEntry( + content, + 1.0f, + null, + null, + null, + null, + observedAt(segment), + segment.rawDataId(), + null, + insightTypes, + Map.copyOf(metadata), + MemoryItemType.FACT, + category, + graphHints); + } + + private Map baseMetadata(EpisodeMetadata episode) { + var metadata = new LinkedHashMap(); + copy(episode.raw(), metadata, "episodeId"); + copy(episode.raw(), metadata, "sessionId"); + copy(episode.raw(), metadata, "timelineId"); + copy(episode.raw(), metadata, "sourceClient"); + metadata.put("files", episode.files()); + metadata.put("commands", episode.commands()); + metadata.put("toolNames", episode.toolNames()); + metadata.put("failureSignals", episode.failureSignals()); + return metadata; + } + + private ExtractedGraphHints graphHints( + EpisodeMetadata episode, boolean includeTools, boolean includeFailureSignals) { + var entities = new java.util.ArrayList(); + episode.files().forEach(file -> entities.add(entity(file, "object", 0.9f))); + episode.commands().forEach(command -> entities.add(entity(command, "object", 0.8f))); + if (includeTools) { + episode.toolNames().forEach(tool -> entities.add(entity(tool, "object", 0.7f))); + } + if (includeFailureSignals) { + episode.failureSignals() + .forEach(signal -> entities.add(entity(signal, "concept", 0.8f))); + } + return new ExtractedGraphHints(entities, List.of()); + } + + private static ExtractedGraphHints.ExtractedEntityHint entity( + String name, String entityType, Float salience) { + return new ExtractedGraphHints.ExtractedEntityHint(name, entityType, salience); + } + + private String toolContent( + EpisodeMetadata episode, + String toolName, + String command, + int successCount, + int failCount) { + if (command != null && !episode.files().isEmpty()) { + return "Use %s to validate changes touching %s." + .formatted(command, String.join(", ", episode.files())); + } + if (command != null) { + return "%s command %s failed %s and passed %s in episode %s." + .formatted( + toolName == null ? "Agent" : toolName, + command, + countWord(failCount), + countWord(successCount), + episode.episodeId()); + } + return "Use %s during agent episodes." + .formatted(toolName == null ? "agent tools" : toolName); + } + + private static String countWord(int count) { + return count == 1 ? "once" : count + " times"; + } + + private static Instant observedAt(ParsedSegment segment) { + return segment.runtimeContext() == null ? null : segment.runtimeContext().observedAt(); + } + + private static boolean isAgentEpisode(ParsedSegment segment) { + return segment != null + && segment.metadata() != null + && "agent_episode".equals(segment.metadata().get("segmentType")); + } + + private static void copy(Map source, Map target, String key) { + if (source.get(key) != null) { + target.put(key, source.get(key)); + } + } + + record EpisodeMetadata(Map raw) { + + static EpisodeMetadata from(ParsedSegment segment) { + return new EpisodeMetadata(segment.metadata() == null ? Map.of() : segment.metadata()); + } + + String episodeId() { + return string(raw.get("episodeId")); + } + + String outcome() { + return string(raw.get("outcome")); + } + + List files() { + return stringList(raw.get("files")); + } + + List commands() { + return stringList(raw.get("commands")); + } + + List toolNames() { + return stringList(raw.get("toolNames")); + } + + List failureSignals() { + return stringList(raw.get("failureSignals")); + } + + List eventIds() { + return stringList(raw.get("eventIds")); + } + + List commandEvents() { + Object value = raw.get("commandEvents"); + if (!(value instanceof List list)) { + return List.of(); + } + return list.stream() + .filter(Map.class::isInstance) + .map(entry -> CommandEvent.from((Map) entry)) + .toList(); + } + + List fileEvents() { + Object value = raw.get("fileEvents"); + if (!(value instanceof List list)) { + return List.of(); + } + return list.stream() + .filter(Map.class::isInstance) + .map(entry -> FileEvent.from((Map) entry)) + .toList(); + } + + int successCount(String command) { + return (int) + commandEvents().stream() + .filter(event -> command == null || command.equals(event.command())) + .filter(CommandEvent::success) + .count(); + } + + int failCount(String command) { + return (int) + commandEvents().stream() + .filter(event -> command == null || command.equals(event.command())) + .filter(CommandEvent::failed) + .count(); + } + + boolean resolvedOutcome() { + return "success".equalsIgnoreCase(outcome()) + || "partial_success".equalsIgnoreCase(outcome()); + } + + Validation validation() { + for (CommandEvent failed : commandEvents()) { + if (!failed.failed()) { + continue; + } + for (CommandEvent candidate : commandEvents()) { + if (candidate.seq() <= failed.seq() || !candidate.success()) { + continue; + } + if (sameCommandFamily(failed.command(), candidate.command())) { + var evidence = new LinkedHashSet(); + addIfPresent(evidence, failed.eventId()); + fileEvents().stream() + .filter( + file -> + file.seq() > failed.seq() + && file.seq() < candidate.seq()) + .map(FileEvent::eventId) + .forEach(id -> addIfPresent(evidence, id)); + addIfPresent(evidence, candidate.eventId()); + return new Validation(candidate.command(), List.copyOf(evidence)); + } + } + } + return null; + } + } + + record CommandEvent(String eventId, int seq, String command, String status, String output) { + + static CommandEvent from(Map map) { + return new CommandEvent( + string(map.get("eventId")), + intValue(map.get("seq")), + string(map.get("command")), + string(map.get("status")), + string(map.get("output"))); + } + + boolean success() { + return "success".equalsIgnoreCase(status); + } + + boolean failed() { + return "failed".equalsIgnoreCase(status); + } + } + + record FileEvent(String eventId, int seq, String path) { + + static FileEvent from(Map map) { + return new FileEvent( + string(map.get("eventId")), intValue(map.get("seq")), string(map.get("path"))); + } + } + + record Validation(String command, List evidenceEventIds) {} + + private static boolean sameCommandFamily(String failedCommand, String successCommand) { + String failedTarget = commandFamily(failedCommand); + String successTarget = commandFamily(successCommand); + return !failedTarget.isBlank() && failedTarget.equals(successTarget); + } + + private static String commandFamily(String command) { + if (command == null || command.isBlank()) { + return ""; + } + String normalized = + command.replaceAll("\\s+", " ").trim().toLowerCase(java.util.Locale.ROOT); + normalized = normalized.replace(" -- ", " "); + var parts = new java.util.ArrayList<>(List.of(normalized.split(" "))); + parts.removeIf( + part -> + part.isBlank() + || part.startsWith("-") + || "npm".equals(part) + || "pnpm".equals(part) + || "yarn".equals(part) + || "run".equals(part)); + if (parts.isEmpty()) { + return normalized; + } + if ("test".equals(parts.getFirst()) && parts.size() > 1) { + return "test:" + parts.get(1); + } + return String.join(" ", parts); + } + + private static List stringList(Object value) { + if (!(value instanceof List list)) { + return List.of(); + } + return list.stream() + .map(AgentMemoryItemFactory::string) + .filter(item -> !item.isBlank()) + .distinct() + .toList(); + } + + private static String string(Object value) { + return value == null ? "" : value.toString(); + } + + private static int intValue(Object value) { + if (value instanceof Number number) { + return number.intValue(); + } + try { + return Integer.parseInt(string(value)); + } catch (NumberFormatException ignored) { + return 0; + } + } + + private static void addIfPresent(LinkedHashSet values, String value) { + if (value != null && !value.isBlank()) { + values.add(value); + } + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java new file mode 100644 index 00000000..a3a5fff1 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java @@ -0,0 +1,238 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.item; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.openmemind.ai.memory.core.data.DefaultInsightTypes; +import com.openmemind.ai.memory.core.data.enums.MemoryCategory; +import com.openmemind.ai.memory.core.data.enums.MemoryItemType; +import com.openmemind.ai.memory.core.data.enums.MemoryScope; +import com.openmemind.ai.memory.core.extraction.item.ItemExtractionConfig; +import com.openmemind.ai.memory.core.extraction.item.support.ExtractedGraphHints; +import com.openmemind.ai.memory.core.extraction.item.support.ExtractedMemoryEntry; +import com.openmemind.ai.memory.core.extraction.rawdata.ParsedSegment; +import com.openmemind.ai.memory.core.extraction.rawdata.segment.SegmentRuntimeContext; +import com.openmemind.ai.memory.plugin.rawdata.agent.content.AgentTimelineContent; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class AgentItemExtractionStrategyTest { + + private final AgentItemExtractionStrategy strategy = new AgentItemExtractionStrategy(); + + @Test + void shouldExtractDeterministicToolAndResolutionFromSuccessfulEpisode() { + List entries = + strategy.extract( + List.of(successfulEpisode()), + DefaultInsightTypes.all(), + agentConfig()) + .block(); + + assertThat(entries) + .anySatisfy( + entry -> { + assertThat(entry.category()).isEqualTo("tool"); + assertThat(entry.type()).isEqualTo(MemoryItemType.FACT); + assertThat(entry.insightTypes()).containsExactly("tools"); + assertThat(entry.metadata()).containsEntry("episodeId", "episode-123"); + assertThat(entry.metadata()) + .containsEntry("command", "npm test payment"); + assertThat(entry.metadata()).containsEntry("successCount", 1); + assertThat(entry.metadata()).containsEntry("failCount", 1); + assertThat(entry.graphHints().entities()) + .extracting(ExtractedGraphHints.ExtractedEntityHint::name) + .contains("Bash", "npm test payment", "src/payment/calc.ts"); + }); + assertThat(entries) + .anySatisfy( + entry -> { + assertThat(entry.category()).isEqualTo("resolution"); + assertThat(entry.insightTypes()).containsExactly("resolutions"); + assertThat(entry.metadata()).containsKey("evidenceEventIds"); + assertThat(entry.metadata().get("evidenceEventIds")) + .asList() + .containsExactly("e2", "e3", "e4"); + assertThat(entry.content()) + .contains( + "rounding mismatch", + "src/payment/calc.ts", + "npm test payment"); + assertThat(entry.graphHints().entities()) + .extracting(ExtractedGraphHints.ExtractedEntityHint::entityType) + .contains("object", "concept"); + }); + } + + @Test + void shouldNotExtractResolutionOrPlaybookFromFailedUnresolvedEpisode() { + List entries = + strategy.extract(List.of(failedEpisode()), DefaultInsightTypes.all(), agentConfig()) + .block(); + + assertThat(entries).anyMatch(entry -> "tool".equals(entry.category())); + assertThat(entries).noneMatch(entry -> "playbook".equals(entry.category())); + assertThat(entries).noneMatch(entry -> "resolution".equals(entry.category())); + } + + @Test + void shouldIgnoreUnrelatedSuccessfulCommandsForResolutionValidation() { + ParsedSegment segment = + segment( + Map.ofEntries( + Map.entry("segmentType", "agent_episode"), + Map.entry("episodeId", "episode-123"), + Map.entry("sourceClient", "codex"), + Map.entry("sessionId", "session-123"), + Map.entry("timelineId", "timeline-123"), + Map.entry("outcome", "success"), + Map.entry("files", List.of("src/payment/calc.ts")), + Map.entry("commands", List.of("npm test payment", "git status")), + Map.entry("toolNames", List.of("Bash", "Edit")), + Map.entry("failureSignals", List.of("rounding mismatch")), + Map.entry("eventIds", List.of("e1", "e2", "e3", "e4")), + Map.entry( + "commandEvents", + List.of( + commandEvent( + "e2", + 2, + "npm test payment", + "failed", + "rounding mismatch"), + commandEvent( + "e4", + 4, + "git status", + "success", + "clean"))))); + + List entries = + strategy.extract(List.of(segment), DefaultInsightTypes.all(), agentConfig()) + .block(); + + assertThat(entries).noneMatch(entry -> "resolution".equals(entry.category())); + } + + @Test + void shouldProduceStableCanonicalContentForDuplicateExtraction() { + var first = + strategy.extract( + List.of(successfulEpisode()), + DefaultInsightTypes.all(), + agentConfig()) + .block(); + var second = + strategy.extract( + List.of(successfulEpisode()), + DefaultInsightTypes.all(), + agentConfig()) + .block(); + + assertThat(first).isNotEmpty(); + assertThat(first.getFirst().content()).isEqualTo(second.getFirst().content()); + } + + private static ParsedSegment successfulEpisode() { + return segment( + Map.ofEntries( + Map.entry("segmentType", "agent_episode"), + Map.entry("episodeId", "episode-123"), + Map.entry("sourceClient", "codex"), + Map.entry("sessionId", "session-123"), + Map.entry("timelineId", "timeline-123"), + Map.entry("outcome", "success"), + Map.entry("files", List.of("src/payment/calc.ts")), + Map.entry("commands", List.of("npm test payment")), + Map.entry("toolNames", List.of("Bash", "Edit")), + Map.entry("failureSignals", List.of("rounding mismatch")), + Map.entry("eventIds", List.of("e1", "e2", "e3", "e4", "e5")), + Map.entry( + "commandEvents", + List.of( + commandEvent( + "e2", + 2, + "npm test payment", + "failed", + "rounding mismatch"), + commandEvent( + "e4", 4, "npm test payment", "success", "passed"))), + Map.entry( + "fileEvents", List.of(fileEvent("e3", 3, "src/payment/calc.ts"))))); + } + + private static ParsedSegment failedEpisode() { + return segment( + Map.ofEntries( + Map.entry("segmentType", "agent_episode"), + Map.entry("episodeId", "episode-456"), + Map.entry("sourceClient", "codex"), + Map.entry("sessionId", "session-123"), + Map.entry("timelineId", "timeline-123"), + Map.entry("outcome", "failed"), + Map.entry("files", List.of("src/payment/calc.ts")), + Map.entry("commands", List.of("npm test payment")), + Map.entry("toolNames", List.of("Bash")), + Map.entry("failureSignals", List.of("rounding mismatch")), + Map.entry("eventIds", List.of("e1", "e2")), + Map.entry( + "commandEvents", + List.of( + commandEvent( + "e2", + 2, + "npm test payment", + "failed", + "rounding mismatch"))))); + } + + private static Map commandEvent( + String eventId, int seq, String command, String status, String output) { + return Map.of( + "eventId", eventId, "seq", seq, "command", command, "status", status, "output", + output); + } + + private static Map fileEvent(String eventId, int seq, String path) { + return Map.of("eventId", eventId, "seq", seq, "path", path, "operation", "edit"); + } + + private static ParsedSegment segment(Map metadata) { + return new ParsedSegment( + "Goal: Fix payment tests", + null, + 0, + 23, + "raw-123", + metadata, + new SegmentRuntimeContext( + Instant.parse("2026-05-24T10:00:00Z"), + Instant.parse("2026-05-24T10:04:00Z"), + null, + "codex")); + } + + private static ItemExtractionConfig agentConfig() { + return new ItemExtractionConfig( + MemoryScope.AGENT, + AgentTimelineContent.TYPE, + MemoryCategory.agentCategories(), + false, + "en"); + } +} From a1b2880bd56d76c9b0a247243294ca18d97788e9 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 25 May 2026 11:22:19 +0800 Subject: [PATCH 10/54] feat(rawdata-agent): extract playbooks and directives --- .../item/AgentItemExtractionStrategy.java | 254 +++++++++++++- .../rawdata/agent/item/AgentItemPrompts.java | 136 ++++++++ .../AgentItemExtractionStrategyLlmTest.java | 327 ++++++++++++++++++ 3 files changed, 716 insertions(+), 1 deletion(-) create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemPrompts.java create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyLlmTest.java diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java index 5dbdbd41..10eb6858 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java @@ -14,14 +14,25 @@ package com.openmemind.ai.memory.plugin.rawdata.agent.item; import com.openmemind.ai.memory.core.data.MemoryInsightType; +import com.openmemind.ai.memory.core.data.enums.MemoryCategory; +import com.openmemind.ai.memory.core.data.enums.MemoryItemType; import com.openmemind.ai.memory.core.extraction.item.ItemExtractionConfig; import com.openmemind.ai.memory.core.extraction.item.ItemExtractionStrategy; +import com.openmemind.ai.memory.core.extraction.item.support.ExtractedGraphHintConverter; import com.openmemind.ai.memory.core.extraction.item.support.ExtractedMemoryEntry; +import com.openmemind.ai.memory.core.extraction.item.support.MemoryItemExtractionResponse; import com.openmemind.ai.memory.core.extraction.rawdata.ParsedSegment; +import com.openmemind.ai.memory.core.llm.ChatMessages; import com.openmemind.ai.memory.core.llm.StructuredChatClient; import com.openmemind.ai.memory.core.prompt.PromptRegistry; import com.openmemind.ai.memory.plugin.rawdata.agent.config.AgentExtractionOptions; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -70,10 +81,251 @@ public Mono> extract( return Mono.just(List.of()); } return Flux.fromIterable(segments) - .flatMapIterable(memoryItemFactory::deterministicEntries) + .flatMap(segment -> extractSegment(segment, config)) + .flatMapIterable(entries -> entries) .collectList(); } + private Mono> extractSegment( + ParsedSegment segment, ItemExtractionConfig config) { + List deterministicEntries = + memoryItemFactory.deterministicEntries(segment); + List categories = enabledCategories(segment, config); + if (chatClient == null || categories.isEmpty()) { + return Mono.just(deterministicEntries); + } + + var prompt = AgentItemPrompts.build(segment, categories).render(language(config)); + return chatClient + .call( + ChatMessages.systemUser(prompt.systemPrompt(), prompt.userPrompt()), + MemoryItemExtractionResponse.class) + .map( + response -> + merge( + deterministicEntries, + toLlmEntries(response, segment, categories))) + .switchIfEmpty(Mono.just(deterministicEntries)) + .onErrorResume(ignored -> Mono.just(deterministicEntries)); + } + + private List enabledCategories(ParsedSegment segment, ItemExtractionConfig config) { + if (!isAgentEpisode(segment) || segment.metadata() == null) { + return List.of(); + } + int eventCount = stringList(segment.metadata().get("eventIds")).size(); + if (eventCount < options.minEventsForExtraction()) { + return List.of(); + } + + Set allowed = + config == null || config.allowedCategories() == null + ? MemoryCategory.agentCategories() + : config.allowedCategories(); + var categories = new ArrayList(); + addIfEnabled(categories, allowed, MemoryCategory.TOOL, options.extractTool()); + addIfEnabled(categories, allowed, MemoryCategory.RESOLUTION, options.extractResolution()); + addIfEnabled( + categories, + allowed, + MemoryCategory.PLAYBOOK, + options.extractPlaybook() + && eventCount >= options.minEventsForPlaybook() + && (!options.requireSuccessForPlaybook() + || successfulOutcome(segment.metadata().get("outcome")))); + addIfEnabled(categories, allowed, MemoryCategory.DIRECTIVE, options.extractDirective()); + return List.copyOf(categories); + } + + private static void addIfEnabled( + List categories, + Set allowed, + MemoryCategory category, + boolean enabled) { + if (enabled && allowed.contains(category)) { + categories.add(category.categoryName()); + } + } + + private static boolean successfulOutcome(Object outcome) { + String value = string(outcome); + return "success".equalsIgnoreCase(value) || "partial_success".equalsIgnoreCase(value); + } + + private static List merge( + List deterministicEntries, + List llmEntries) { + if (llmEntries.isEmpty()) { + return deterministicEntries; + } + var merged = + new ArrayList( + deterministicEntries.size() + llmEntries.size()); + merged.addAll(deterministicEntries); + merged.addAll(llmEntries); + return List.copyOf(merged); + } + + private List toLlmEntries( + MemoryItemExtractionResponse response, ParsedSegment segment, List categories) { + if (response == null || response.items() == null || response.items().isEmpty()) { + return List.of(); + } + return response.items().stream() + .filter(item -> isValidItem(item, segment, categories)) + .map(item -> toEntry(item, segment)) + .toList(); + } + + private static ExtractedMemoryEntry toEntry( + MemoryItemExtractionResponse.ExtractedItem item, ParsedSegment segment) { + return new ExtractedMemoryEntry( + item.content(), + clamp(item.confidence()), + occurredAt(item.occurredAt()), + null, + null, + null, + observedAt(segment), + segment.rawDataId(), + null, + List.of(expectedInsightType(item.category())), + metadata(segment, item), + MemoryItemType.FACT, + normalize(item.category()), + ExtractedGraphHintConverter.from(item)); + } + + private static boolean isValidItem( + MemoryItemExtractionResponse.ExtractedItem item, + ParsedSegment segment, + List categories) { + if (item == null || item.content() == null || item.content().isBlank()) { + return false; + } + String category = normalize(item.category()); + if (!categories.contains(category)) { + return false; + } + String expectedInsightType = expectedInsightType(category); + if (item.insightTypes() == null || !item.insightTypes().contains(expectedInsightType)) { + return false; + } + List evidenceEventIds = evidenceEventIds(item.metadata()); + if (evidenceEventIds.isEmpty()) { + return false; + } + List segmentEventIds = stringList(segment.metadata().get("eventIds")); + if (!segmentEventIds.containsAll(evidenceEventIds)) { + return false; + } + if (MemoryCategory.PLAYBOOK.categoryName().equals(category) && !validPlaybook(item)) { + return false; + } + return !MemoryCategory.RESOLUTION.categoryName().equals(category) || validResolution(item); + } + + private static boolean validPlaybook(MemoryItemExtractionResponse.ExtractedItem item) { + Map metadata = item.metadata(); + return metadata != null + && !string(metadata.get("trigger")).isBlank() + && stringList(metadata.get("steps")).size() >= 2 + && !string(metadata.get("expectedOutcome")).isBlank(); + } + + private static boolean validResolution(MemoryItemExtractionResponse.ExtractedItem item) { + Map metadata = item.metadata(); + return metadata != null + && !string(metadata.get("problem")).isBlank() + && (!string(metadata.get("fix")).isBlank() + || !string(metadata.get("conclusion")).isBlank()); + } + + private static Map metadata( + ParsedSegment segment, MemoryItemExtractionResponse.ExtractedItem item) { + var metadata = new LinkedHashMap(); + if (item.metadata() != null) { + metadata.putAll(item.metadata()); + } + copy(segment.metadata(), metadata, "episodeId"); + copy(segment.metadata(), metadata, "sessionId"); + copy(segment.metadata(), metadata, "timelineId"); + copy(segment.metadata(), metadata, "sourceClient"); + copy(segment.metadata(), metadata, "outcome"); + copy(segment.metadata(), metadata, "files"); + copy(segment.metadata(), metadata, "commands"); + copy(segment.metadata(), metadata, "toolNames"); + copy(segment.metadata(), metadata, "failureSignals"); + return Map.copyOf(metadata); + } + + private static void copy(Map source, Map target, String key) { + if (source != null && source.get(key) != null) { + target.put(key, source.get(key)); + } + } + + private static List evidenceEventIds(Map metadata) { + return metadata == null ? List.of() : stringList(metadata.get("evidenceEventIds")); + } + + private static String expectedInsightType(String category) { + return switch (normalize(category)) { + case "tool" -> "tools"; + case "resolution" -> "resolutions"; + case "playbook" -> "playbooks"; + case "directive" -> "directives"; + default -> ""; + }; + } + + private static Instant occurredAt(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return Instant.parse(value); + } catch (Exception ignored) { + return null; + } + } + + private static Instant observedAt(ParsedSegment segment) { + return segment.runtimeContext() == null ? null : segment.runtimeContext().observedAt(); + } + + private static float clamp(float value) { + return Math.max(0.0f, Math.min(1.0f, value)); + } + + private static boolean isAgentEpisode(ParsedSegment segment) { + return segment != null + && segment.metadata() != null + && "agent_episode".equals(segment.metadata().get("segmentType")); + } + + private static List stringList(Object value) { + if (!(value instanceof List list)) { + return List.of(); + } + return list.stream() + .map(AgentItemExtractionStrategy::string) + .filter(item -> !item.isBlank()) + .toList(); + } + + private static String normalize(String value) { + return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + } + + private static String string(Object value) { + return value == null ? "" : value.toString(); + } + + private static String language(ItemExtractionConfig config) { + return config == null ? "en" : config.language(); + } + public StructuredChatClient chatClient() { return chatClient; } diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemPrompts.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemPrompts.java new file mode 100644 index 00000000..2180e578 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemPrompts.java @@ -0,0 +1,136 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.item; + +import com.openmemind.ai.memory.core.extraction.rawdata.ParsedSegment; +import com.openmemind.ai.memory.core.prompt.PromptTemplate; +import java.util.List; +import java.util.Map; + +/** + * Prompt builder for agent episode memory extraction. + */ +public final class AgentItemPrompts { + + private static final String SYSTEM = + """ + You extract durable AGENT-scope memory items from one deterministic coding-agent \ + episode. The episode has already been parsed from raw agent events; do not invent \ + events, tools, files, or outcomes that are not present in the input. + + Categories are limited to: {{categories}}. + Do not emit user-scope categories such as profile, behavior, or event. + + Evidence rules: + - Every item must include metadata.evidenceEventIds. + - evidenceEventIds must be a non-empty subset of the provided episode event IDs. + - Prefer the smallest evidence set that proves the memory. + + Category rules: + - tool: concrete command or tool usage knowledge grounded in observed tool events. + - resolution: resolved problem knowledge only; metadata must include problem and \ + fix or conclusion. + - playbook: reusable workflow only; metadata must include trigger, at least two \ + steps, and expectedOutcome. + - directive: durable instruction, collaboration boundary, or stable agent behavior \ + rule that should be reused in later sessions. + + Graph rules: + - Entity types must use the current core graph vocabulary only: person, \ + organization, place, object, concept, other, special. + - Causal relations must use only caused_by, enabled_by, motivated_by. + - Causal relation indexes must reference item indexes in this response. + + Return only a JSON object matching: + { + "items": [ + { + "content": "durable memory sentence", + "confidence": 0.0, + "occurredAt": null, + "insightTypes": ["tools|resolutions|playbooks|directives"], + "metadata": { + "evidenceEventIds": ["event-id"], + "...": "category-specific fields" + }, + "category": "tool|resolution|playbook|directive", + "entities": [ + {"name": "entity", "entityType": "object", "salience": 0.8} + ], + "causalRelations": [ + {"causeIndex": 0, "effectIndex": 1, "relationType": "enabled_by", \ + "strength": 0.8} + ] + } + ] + } + """; + + private static final String USER_PROMPT = + """ + # Episode Metadata + + episodeId: {{episode_id}} + sourceClient: {{source_client}} + sessionId: {{session_id}} + timelineId: {{timeline_id}} + outcome: {{outcome}} + files: {{files}} + commands: {{commands}} + toolNames: {{tool_names}} + failureSignals: {{failure_signals}} + eventIds: {{event_ids}} + + # Episode Text + + {{episode_text}} + """; + + private AgentItemPrompts() {} + + public static PromptTemplate build(ParsedSegment segment, List categories) { + Map metadata = segment.metadata() == null ? Map.of() : segment.metadata(); + return PromptTemplate.builder("agent-item") + .section("system", SYSTEM) + .userPrompt(USER_PROMPT) + .variable("categories", String.join(", ", categories)) + .variable("episode_id", string(metadata.get("episodeId"))) + .variable("source_client", string(metadata.get("sourceClient"))) + .variable("session_id", string(metadata.get("sessionId"))) + .variable("timeline_id", string(metadata.get("timelineId"))) + .variable("outcome", string(metadata.get("outcome"))) + .variable("files", formatList(metadata.get("files"))) + .variable("commands", formatList(metadata.get("commands"))) + .variable("tool_names", formatList(metadata.get("toolNames"))) + .variable("failure_signals", formatList(metadata.get("failureSignals"))) + .variable("event_ids", formatList(metadata.get("eventIds"))) + .variable("episode_text", segment.text() == null ? "" : segment.text()) + .build(); + } + + private static String formatList(Object value) { + if (!(value instanceof List list) || list.isEmpty()) { + return "[]"; + } + return list.stream() + .map(AgentItemPrompts::string) + .filter(item -> !item.isBlank()) + .toList() + .toString(); + } + + private static String string(Object value) { + return value == null ? "" : value.toString(); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyLlmTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyLlmTest.java new file mode 100644 index 00000000..e57204b5 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyLlmTest.java @@ -0,0 +1,327 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.item; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.openmemind.ai.memory.core.data.DefaultInsightTypes; +import com.openmemind.ai.memory.core.data.enums.MemoryCategory; +import com.openmemind.ai.memory.core.data.enums.MemoryScope; +import com.openmemind.ai.memory.core.extraction.item.ItemExtractionConfig; +import com.openmemind.ai.memory.core.extraction.item.support.ExtractedMemoryEntry; +import com.openmemind.ai.memory.core.extraction.item.support.MemoryItemExtractionResponse; +import com.openmemind.ai.memory.core.extraction.rawdata.ParsedSegment; +import com.openmemind.ai.memory.core.extraction.rawdata.segment.SegmentRuntimeContext; +import com.openmemind.ai.memory.core.llm.ChatMessage; +import com.openmemind.ai.memory.core.llm.StructuredChatClient; +import com.openmemind.ai.memory.core.prompt.PromptRegistry; +import com.openmemind.ai.memory.plugin.rawdata.agent.config.AgentExtractionOptions; +import com.openmemind.ai.memory.plugin.rawdata.agent.content.AgentTimelineContent; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +class AgentItemExtractionStrategyLlmTest { + + @Test + void shouldMergeValidLlmPlaybookWithDeterministicMetadataAndGraphHints() { + var client = + new StubStructuredChatClient( + response( + new MemoryItemExtractionResponse.ExtractedItem( + "When payment tests fail with rounding mismatch, inspect" + + " policy, edit calc.ts, then run npm test payment.", + 0.86f, + null, + null, + List.of("playbooks"), + Map.of( + "trigger", + "payment tests fail with rounding mismatch", + "steps", + List.of( + "Inspect policy", + "Edit calc.ts", + "Run npm test payment"), + "expectedOutcome", + "payment tests pass", + "evidenceEventIds", + List.of("e3", "e4", "e5")), + "playbook", + List.of( + new MemoryItemExtractionResponse.ExtractedEntity( + "src/payment/calc.ts", "object", 0.9f), + new MemoryItemExtractionResponse.ExtractedEntity( + "rounding mismatch", "concept", 0.8f)), + List.of()))); + AgentItemExtractionStrategy strategy = strategy(client); + + List entries = + strategy.extract( + List.of(successfulEpisode()), + DefaultInsightTypes.all(), + agentConfig()) + .block(); + + assertThat(client.calls()).isEqualTo(1); + assertThat(client.lastMessages()) + .anySatisfy( + message -> + assertThat(message.content()) + .contains( + "Categories are limited to", + "evidenceEventIds", + "caused_by", + "enabled_by", + "motivated_by")); + assertThat(entries) + .anySatisfy( + entry -> { + assertThat(entry.category()).isEqualTo("playbook"); + assertThat(entry.insightTypes()).containsExactly("playbooks"); + assertThat(entry.metadata()) + .containsEntry("episodeId", "episode-123") + .containsEntry("sessionId", "session-123") + .containsEntry("timelineId", "timeline-123") + .containsEntry("sourceClient", "codex"); + assertThat(entry.metadata().get("evidenceEventIds")) + .asList() + .containsExactly("e3", "e4", "e5"); + assertThat(entry.graphHints().entities()) + .extracting( + com.openmemind.ai.memory.core.extraction.item.support + .ExtractedGraphHints.ExtractedEntityHint + ::name) + .contains("src/payment/calc.ts", "rounding mismatch"); + }); + } + + @Test + void shouldDropInvalidLlmItems() { + var client = + new StubStructuredChatClient( + response( + new MemoryItemExtractionResponse.ExtractedItem( + "Too thin playbook", + 0.9f, + null, + List.of("playbooks"), + Map.of( + "trigger", + "payment tests fail", + "steps", + List.of("Run tests"), + "expectedOutcome", + "tests pass", + "evidenceEventIds", + List.of("e3")), + "playbook"), + new MemoryItemExtractionResponse.ExtractedItem( + "Problem only", + 0.9f, + null, + List.of("resolutions"), + Map.of( + "problem", + "rounding mismatch", + "evidenceEventIds", + List.of("e3")), + "resolution"), + new MemoryItemExtractionResponse.ExtractedItem( + "User likes test output", + 0.9f, + null, + List.of("preferences"), + Map.of("evidenceEventIds", List.of("e3")), + "profile"), + new MemoryItemExtractionResponse.ExtractedItem( + "Evidence outside this episode", + 0.9f, + null, + List.of("directives"), + Map.of("evidenceEventIds", List.of("missing")), + "directive"))); + AgentItemExtractionStrategy strategy = strategy(client); + + List entries = + strategy.extract( + List.of(successfulEpisode()), + DefaultInsightTypes.all(), + agentConfig()) + .block(); + + assertThat(entries) + .noneMatch( + entry -> + "playbook".equals(entry.category()) + || "directive".equals(entry.category())); + assertThat(entries.stream().filter(entry -> "resolution".equals(entry.category())).count()) + .isEqualTo(1); + } + + @Test + void shouldSkipLlmWhenEpisodeDoesNotMeetMinimumEventThreshold() { + var client = + new StubStructuredChatClient( + response( + new MemoryItemExtractionResponse.ExtractedItem( + "Never called", + 0.9f, + null, + List.of("directives"), + Map.of("evidenceEventIds", List.of("e1")), + "directive"))); + AgentItemExtractionStrategy strategy = strategy(client); + + List entries = + strategy.extract(List.of(shortEpisode()), DefaultInsightTypes.all(), agentConfig()) + .block(); + + assertThat(client.calls()).isZero(); + assertThat(entries).anyMatch(entry -> "tool".equals(entry.category())); + } + + private static AgentItemExtractionStrategy strategy(StructuredChatClient client) { + return new AgentItemExtractionStrategy( + client, + PromptRegistry.EMPTY, + AgentExtractionOptions.defaults(), + new AgentMemoryItemFactory()); + } + + private static MemoryItemExtractionResponse response( + MemoryItemExtractionResponse.ExtractedItem... items) { + return new MemoryItemExtractionResponse(List.of(items)); + } + + private static ParsedSegment successfulEpisode() { + return segment( + Map.ofEntries( + Map.entry("segmentType", "agent_episode"), + Map.entry("episodeId", "episode-123"), + Map.entry("sourceClient", "codex"), + Map.entry("sessionId", "session-123"), + Map.entry("timelineId", "timeline-123"), + Map.entry("outcome", "success"), + Map.entry("files", List.of("src/payment/calc.ts")), + Map.entry("commands", List.of("npm test payment")), + Map.entry("toolNames", List.of("Bash", "Edit")), + Map.entry("failureSignals", List.of("rounding mismatch")), + Map.entry("eventIds", List.of("e1", "e2", "e3", "e4", "e5")), + Map.entry( + "commandEvents", + List.of( + commandEvent( + "e2", + 2, + "npm test payment", + "failed", + "rounding mismatch"), + commandEvent( + "e4", 4, "npm test payment", "success", "passed"))), + Map.entry( + "fileEvents", List.of(fileEvent("e3", 3, "src/payment/calc.ts"))))); + } + + private static ParsedSegment shortEpisode() { + return segment( + Map.ofEntries( + Map.entry("segmentType", "agent_episode"), + Map.entry("episodeId", "episode-short"), + Map.entry("sourceClient", "codex"), + Map.entry("sessionId", "session-123"), + Map.entry("timelineId", "timeline-123"), + Map.entry("outcome", "success"), + Map.entry("commands", List.of("npm test payment")), + Map.entry("toolNames", List.of("Bash")), + Map.entry("eventIds", List.of("e1", "e2")), + Map.entry( + "commandEvents", + List.of( + commandEvent( + "e2", + 2, + "npm test payment", + "success", + "passed"))))); + } + + private static ParsedSegment segment(Map metadata) { + return new ParsedSegment( + "Goal: Fix payment tests", + null, + 0, + 23, + "raw-123", + metadata, + new SegmentRuntimeContext( + Instant.parse("2026-05-24T10:00:00Z"), + Instant.parse("2026-05-24T10:04:00Z"), + null, + "codex")); + } + + private static Map commandEvent( + String eventId, int seq, String command, String status, String output) { + return Map.of( + "eventId", eventId, "seq", seq, "command", command, "status", status, "output", + output); + } + + private static Map fileEvent(String eventId, int seq, String path) { + return Map.of("eventId", eventId, "seq", seq, "path", path, "operation", "edit"); + } + + private static ItemExtractionConfig agentConfig() { + return new ItemExtractionConfig( + MemoryScope.AGENT, + AgentTimelineContent.TYPE, + MemoryCategory.agentCategories(), + false, + "en"); + } + + private static final class StubStructuredChatClient implements StructuredChatClient { + + private final MemoryItemExtractionResponse response; + private int calls; + private List lastMessages = List.of(); + + private StubStructuredChatClient(MemoryItemExtractionResponse response) { + this.response = response; + } + + @Override + public Mono call(List messages) { + return Mono.error(new UnsupportedOperationException("not used by this test")); + } + + @Override + public Mono call(List messages, Class responseType) { + calls++; + lastMessages = List.copyOf(messages); + return Mono.just(responseType.cast(response)); + } + + int calls() { + return calls; + } + + List lastMessages() { + return lastMessages; + } + } +} From 9d55f16b9234d8005501a9f43da8805c51f15a51 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 25 May 2026 11:50:48 +0800 Subject: [PATCH 11/54] test(rawdata-agent): cover extraction pipeline --- .../agent/chunk/AgentSegmentFormatter.java | 51 ++ .../chunk/AgentSegmentFormatterTest.java | 42 ++ ...gentExtractionPipelineIntegrationTest.java | 571 ++++++++++++++++++ 3 files changed, 664 insertions(+) create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/integration/AgentExtractionPipelineIntegrationTest.java diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java index d4b626d4..58d8987c 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java @@ -19,6 +19,7 @@ import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEpisode; import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEvent; import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventStatus; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentFileReference; import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentProject; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -89,6 +90,8 @@ private Map metadata(AgentTimelineContent timeline, AgentEpisode metadata.put("toolNames", episode.toolNames()); metadata.put("failureSignals", episode.failureSignals()); metadata.put("eventIds", episode.eventIds()); + metadata.put("commandEvents", commandEventMetadata(episode.commandEvents())); + metadata.put("fileEvents", fileEventMetadata(episode.fileReferences())); if (episode.startTime() != null) { metadata.put("windowStart", episode.startTime()); } @@ -99,6 +102,54 @@ private Map metadata(AgentTimelineContent timeline, AgentEpisode return Map.copyOf(metadata); } + private static List> commandEventMetadata(List commands) { + if (commands == null || commands.isEmpty()) { + return List.of(); + } + return commands.stream().map(AgentSegmentFormatter::commandEventMetadata).toList(); + } + + private static Map commandEventMetadata(AgentCommand command) { + var metadata = new LinkedHashMap(); + putIfHasText(metadata, "eventId", command.eventId()); + putIfNotNull(metadata, "seq", command.seq()); + putIfHasText(metadata, "command", command.command()); + if (command.status() != null) { + metadata.put("status", command.status().wireValue()); + } + putIfHasText(metadata, "output", command.output()); + putIfNotNull(metadata, "exitCode", command.exitCode()); + return Map.copyOf(metadata); + } + + private static List> fileEventMetadata(List files) { + if (files == null || files.isEmpty()) { + return List.of(); + } + return files.stream().map(AgentSegmentFormatter::fileEventMetadata).toList(); + } + + private static Map fileEventMetadata(AgentFileReference file) { + var metadata = new LinkedHashMap(); + putIfHasText(metadata, "eventId", file.eventId()); + putIfNotNull(metadata, "seq", file.seq()); + putIfHasText(metadata, "path", file.path()); + putIfHasText(metadata, "operation", file.operation()); + return Map.copyOf(metadata); + } + + private static void putIfHasText(Map metadata, String key, String value) { + if (hasText(value)) { + metadata.put(key, value); + } + } + + private static void putIfNotNull(Map metadata, String key, Object value) { + if (value != null) { + metadata.put(key, value); + } + } + private static String formatCommand(AgentCommand command) { String status = command.status() == null diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java index 9307233a..7e2154e6 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java @@ -16,6 +16,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEpisode; +import java.util.Map; import org.junit.jupiter.api.Test; class AgentSegmentFormatterTest { @@ -63,6 +64,47 @@ void shouldFormatEpisodeTextAndMetadataDeterministically() { assertThat(formatted.metadata().get("eventIds")) .asList() .containsExactly("e1", "e2", "e3", "e4", "e5"); + assertThat(formatted.metadata().get("commandEvents")) + .asList() + .containsExactly( + Map.of( + "eventId", + "e2", + "seq", + 2, + "command", + "npm test payment", + "status", + "failed", + "output", + "rounding mismatch", + "exitCode", + 1), + Map.of( + "eventId", + "e4", + "seq", + 4, + "command", + "npm test payment", + "status", + "success", + "output", + "passed", + "exitCode", + 0)); + assertThat(formatted.metadata().get("fileEvents")) + .asList() + .containsExactly( + Map.of( + "eventId", + "e3", + "seq", + 3, + "path", + "src/payment/calc.ts", + "operation", + "edit")); assertThat(formatted.metadata()).doesNotContainKey("projectRootRaw"); assertThat(formatted.metadata()).containsKey("projectRootHash"); } diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/integration/AgentExtractionPipelineIntegrationTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/integration/AgentExtractionPipelineIntegrationTest.java new file mode 100644 index 00000000..bf6881e7 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/integration/AgentExtractionPipelineIntegrationTest.java @@ -0,0 +1,571 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.openmemind.ai.memory.core.Memory; +import com.openmemind.ai.memory.core.buffer.InMemoryConversationBuffer; +import com.openmemind.ai.memory.core.buffer.InMemoryInsightBuffer; +import com.openmemind.ai.memory.core.buffer.InMemoryRecentConversationBuffer; +import com.openmemind.ai.memory.core.buffer.MemoryBuffer; +import com.openmemind.ai.memory.core.builder.ExtractionOptions; +import com.openmemind.ai.memory.core.builder.InsightExtractionOptions; +import com.openmemind.ai.memory.core.builder.ItemExtractionOptions; +import com.openmemind.ai.memory.core.builder.ItemGraphOptions; +import com.openmemind.ai.memory.core.builder.MemoryBuildOptions; +import com.openmemind.ai.memory.core.builder.PromptBudgetOptions; +import com.openmemind.ai.memory.core.builder.RawDataExtractionOptions; +import com.openmemind.ai.memory.core.data.DefaultMemoryId; +import com.openmemind.ai.memory.core.data.MemoryId; +import com.openmemind.ai.memory.core.data.MemoryItem; +import com.openmemind.ai.memory.core.data.MemoryRawData; +import com.openmemind.ai.memory.core.data.enums.MemoryCategory; +import com.openmemind.ai.memory.core.data.enums.MemoryScope; +import com.openmemind.ai.memory.core.extraction.ExtractionConfig; +import com.openmemind.ai.memory.core.extraction.ExtractionRequest; +import com.openmemind.ai.memory.core.extraction.ExtractionResult; +import com.openmemind.ai.memory.core.extraction.insight.scheduler.InsightBuildConfig; +import com.openmemind.ai.memory.core.extraction.item.support.MemoryItemExtractionResponse; +import com.openmemind.ai.memory.core.llm.ChatMessage; +import com.openmemind.ai.memory.core.llm.StructuredChatClient; +import com.openmemind.ai.memory.core.store.InMemoryMemoryStore; +import com.openmemind.ai.memory.core.store.MemoryStore; +import com.openmemind.ai.memory.core.vector.MemoryVector; +import com.openmemind.ai.memory.core.vector.VectorSearchResult; +import com.openmemind.ai.memory.plugin.rawdata.agent.config.AgentRawDataOptions; +import com.openmemind.ai.memory.plugin.rawdata.agent.content.AgentTimelineContent; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEvent; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventKind; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventStatus; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentProject; +import com.openmemind.ai.memory.plugin.rawdata.agent.plugin.AgentRawDataPlugin; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +class AgentExtractionPipelineIntegrationTest { + + private static final MemoryId MEMORY_ID = DefaultMemoryId.of("user-1", "agent-1"); + + @Test + void successfulTimelineProducesAgentToolItemAndAgentEpisodeRawData() { + var fixture = fixture(new ScriptedStructuredChatClient(response())); + + ExtractionResult result = extract(fixture, paymentTimeline(paymentEvents())); + + assertThat(result.isSuccess()) + .withFailMessage("status=%s error=%s", result.status(), result.errorMessage()) + .isTrue(); + assertThat(items(fixture)) + .anySatisfy( + item -> { + assertThat(item.category()).isEqualTo(MemoryCategory.TOOL); + assertThat(item.scope()).isEqualTo(MemoryScope.AGENT); + assertThat(item.metadata().get("insightTypes")) + .asList() + .containsExactly("tools"); + }); + assertThat(rawData(fixture)) + .singleElement() + .satisfies( + rawData -> { + assertThat(rawData.metadata()) + .containsEntry("segmentType", "agent_episode"); + assertThat(rawData.segment().metadata()) + .containsEntry("segmentType", "agent_episode"); + }); + } + + @Test + void failureEditAndSuccessfulValidationProducesResolutionItem() { + var fixture = fixture(new ScriptedStructuredChatClient(response())); + + extract(fixture, paymentTimeline(paymentEvents())); + + assertThat(items(fixture)) + .anySatisfy( + item -> { + assertThat(item.category()).isEqualTo(MemoryCategory.RESOLUTION); + assertThat(item.scope()).isEqualTo(MemoryScope.AGENT); + assertThat(item.content()) + .contains( + "rounding mismatch", + "src/payment/calc.ts", + "npm test payment"); + }); + } + + @Test + void complexSuccessfulEpisodeCanProducePlaybookFromLlm() { + var client = + new ScriptedStructuredChatClient( + response( + new MemoryItemExtractionResponse.ExtractedItem( + "When payment tests fail with rounding mismatch, inspect" + + " policy, edit calc.ts, then run npm test payment.", + 0.86f, + null, + List.of("playbooks"), + Map.of( + "trigger", + "payment tests fail with rounding mismatch", + "steps", + List.of( + "Inspect policy", + "Edit calc.ts", + "Run npm test payment"), + "expectedOutcome", + "payment tests pass", + "evidenceEventIds", + List.of("e3", "e4", "e5")), + "playbook"))); + var fixture = fixture(client); + + extract(fixture, paymentTimeline(paymentEvents())); + + assertThat(client.structuredCalls()).isEqualTo(1); + assertThat(items(fixture)) + .anySatisfy( + item -> { + assertThat(item.category()).isEqualTo(MemoryCategory.PLAYBOOK); + assertThat(item.scope()).isEqualTo(MemoryScope.AGENT); + assertThat(item.metadata().get("insightTypes")) + .asList() + .containsExactly("playbooks"); + }); + } + + @Test + void failedUnresolvedEpisodeDoesNotProducePlaybook() { + var client = + new ScriptedStructuredChatClient( + response( + new MemoryItemExtractionResponse.ExtractedItem( + "Never accepted because the episode failed", + 0.9f, + null, + List.of("playbooks"), + Map.of( + "trigger", + "payment tests fail", + "steps", + List.of("Inspect", "Edit"), + "expectedOutcome", + "tests pass", + "evidenceEventIds", + List.of("e2", "e3")), + "playbook"))); + var fixture = fixture(client); + + extract(fixture, paymentTimeline(failedUnresolvedEvents())); + + assertThat(items(fixture)).noneMatch(item -> item.category() == MemoryCategory.PLAYBOOK); + } + + @Test + void agentPipelineStoresAgentCategoriesOnly() { + var client = + new ScriptedStructuredChatClient( + response( + new MemoryItemExtractionResponse.ExtractedItem( + "User likes concise test output", + 0.9f, + null, + List.of("preferences"), + Map.of("evidenceEventIds", List.of("e3")), + "profile"))); + var fixture = fixture(client); + + extract(fixture, paymentTimeline(paymentEvents())); + + assertThat(items(fixture)).isNotEmpty(); + assertThat(items(fixture)) + .allSatisfy(item -> assertThat(item.scope()).isEqualTo(MemoryScope.AGENT)); + assertThat(items(fixture)) + .extracting(MemoryItem::category) + .doesNotContain( + MemoryCategory.PROFILE, MemoryCategory.BEHAVIOR, MemoryCategory.EVENT); + } + + @Test + void exactDuplicateTimelineWindowDoesNotDuplicateDurableItems() { + var fixture = fixture(new ScriptedStructuredChatClient(response())); + AgentTimelineContent timeline = paymentTimeline(paymentEvents()); + + extract(fixture, timeline); + extract(fixture, timeline); + + assertThat(items(fixture)) + .extracting(MemoryItem::category) + .containsExactlyInAnyOrder(MemoryCategory.TOOL, MemoryCategory.RESOLUTION); + assertThat(rawData(fixture)).hasSize(1); + } + + @Test + void redactionRunsBeforeRawDataPersistence() { + var fixture = fixture(new ScriptedStructuredChatClient(response())); + + extract(fixture, paymentTimeline(secretEvents())); + + assertThat(rawData(fixture)) + .singleElement() + .satisfies( + rawData -> { + assertThat(rawData.segment().content()) + .doesNotContain("sk-live-1234567890abcdef"); + assertThat(rawData.segment().content()).contains("[REDACTED"); + }); + } + + private static ExtractionResult extract(Fixture fixture, AgentTimelineContent timeline) { + return fixture.memory() + .extract(ExtractionRequest.of(MEMORY_ID, timeline).withConfig(agentConfig())) + .block(); + } + + private static ExtractionConfig agentConfig() { + return ExtractionConfig.agentOnly().withEnableInsight(false); + } + + private static Fixture fixture(StructuredChatClient client) { + var store = new InMemoryMemoryStore(); + var vector = new RecordingMemoryVector(); + var memory = + Memory.builder() + .chatClient(client) + .store(store) + .buffer( + MemoryBuffer.of( + new InMemoryInsightBuffer(), + new InMemoryConversationBuffer(), + new InMemoryRecentConversationBuffer())) + .vector(vector) + .rawDataPlugin(new AgentRawDataPlugin(AgentRawDataOptions.defaults())) + .options(memoryOptions()) + .build(); + return new Fixture(memory, store, vector); + } + + private static MemoryBuildOptions memoryOptions() { + return MemoryBuildOptions.builder() + .extraction( + new ExtractionOptions( + com.openmemind.ai.memory.core.builder.ExtractionCommonOptions + .defaults(), + RawDataExtractionOptions.defaults(), + new ItemExtractionOptions( + false, + PromptBudgetOptions.defaults(), + ItemGraphOptions.defaults().withEnabled(false)), + new InsightExtractionOptions( + false, new InsightBuildConfig(100, 100, 100, 100)))) + .build(); + } + + private static List items(Fixture fixture) { + return fixture.store().itemOperations().listItems(MEMORY_ID); + } + + private static List rawData(Fixture fixture) { + return fixture.store().rawDataOperations().listRawData(MEMORY_ID); + } + + private static MemoryItemExtractionResponse response( + MemoryItemExtractionResponse.ExtractedItem... items) { + return new MemoryItemExtractionResponse(List.of(items)); + } + + private static AgentTimelineContent paymentTimeline(List events) { + return new AgentTimelineContent( + "codex", + "1.0", + "session-123", + "timeline-123", + new AgentProject("payments-api", "/Users/alice/work/payments-api", null, Map.of()), + events); + } + + private static List paymentEvents() { + return List.of( + event( + "e1", + 1, + AgentEventKind.USER_PROMPT, + "Fix payment tests", + null, + null, + null, + null, + null, + null, + null, + null), + event( + "e2", + 2, + AgentEventKind.COMMAND, + null, + "Bash", + "rounding mismatch", + AgentEventStatus.FAILED, + null, + null, + "npm test payment", + 1, + Map.of("failureSignal", "rounding mismatch")), + event( + "e3", + 3, + AgentEventKind.FILE_EDIT, + null, + "Edit", + "changed rounding logic", + AgentEventStatus.SUCCESS, + "src/payment/calc.ts", + "edit", + null, + null, + null), + event( + "e4", + 4, + AgentEventKind.COMMAND, + null, + "Bash", + "passed", + AgentEventStatus.SUCCESS, + null, + null, + "npm test payment", + 0, + null), + event( + "e5", + 5, + AgentEventKind.STOP, + "done", + null, + null, + AgentEventStatus.SUCCESS, + null, + null, + null, + null, + null)); + } + + private static List failedUnresolvedEvents() { + return List.of( + event( + "e1", + 1, + AgentEventKind.USER_PROMPT, + "Fix payment tests", + null, + null, + null, + null, + null, + null, + null, + null), + event( + "e2", + 2, + AgentEventKind.COMMAND, + null, + "Bash", + "rounding mismatch", + AgentEventStatus.FAILED, + null, + null, + "npm test payment", + 1, + Map.of("failureSignal", "rounding mismatch")), + event( + "e3", + 3, + AgentEventKind.FILE_EDIT, + null, + "Edit", + "partial change", + AgentEventStatus.SUCCESS, + "src/payment/calc.ts", + "edit", + null, + null, + null)); + } + + private static List secretEvents() { + return List.of( + event( + "e1", + 1, + AgentEventKind.USER_PROMPT, + "Run deployment validation", + null, + null, + null, + null, + null, + null, + null, + null), + event( + "e2", + 2, + AgentEventKind.COMMAND, + null, + "Bash", + "Authorization: Bearer sk-live-1234567890abcdef", + AgentEventStatus.SUCCESS, + null, + null, + "curl https://api.example.test", + 0, + null), + event( + "e3", + 3, + AgentEventKind.STOP, + "done", + null, + null, + AgentEventStatus.SUCCESS, + null, + null, + null, + null, + null)); + } + + private static AgentEvent event( + String id, + int seq, + AgentEventKind kind, + String text, + String toolName, + String output, + AgentEventStatus status, + String path, + String operation, + String command, + Integer exitCode, + Map metadata) { + return new AgentEvent( + id, + seq, + kind, + Instant.parse("2026-05-24T10:00:00Z").plusSeconds(seq * 60L), + text, + toolName, + null, + output, + status, + 10L, + path, + operation, + command, + exitCode, + metadata == null ? Map.of() : metadata); + } + + private record Fixture(Memory memory, MemoryStore store, RecordingMemoryVector vector) {} + + private static final class ScriptedStructuredChatClient implements StructuredChatClient { + + private final MemoryItemExtractionResponse response; + private int structuredCalls; + + private ScriptedStructuredChatClient(MemoryItemExtractionResponse response) { + this.response = response; + } + + @Override + public Mono call(List messages) { + return Mono.error(new UnsupportedOperationException("not used by this test")); + } + + @Override + public Mono call(List messages, Class responseType) { + structuredCalls++; + return Mono.just(responseType.cast(response)); + } + + private int structuredCalls() { + return structuredCalls; + } + } + + private static final class RecordingMemoryVector implements MemoryVector { + + private final AtomicInteger sequence = new AtomicInteger(); + private final List storedTexts = new ArrayList<>(); + + @Override + public Mono store(MemoryId memoryId, String text, Map metadata) { + storedTexts.add(text); + return Mono.just("vec-" + sequence.getAndIncrement()); + } + + @Override + public Mono> storeBatch( + MemoryId memoryId, List texts, List> metadataList) { + storedTexts.addAll(texts); + return Mono.just( + IntStream.range(0, texts.size()) + .mapToObj(i -> "vec-" + sequence.getAndIncrement()) + .toList()); + } + + @Override + public Mono delete(MemoryId memoryId, String vectorId) { + return Mono.empty(); + } + + @Override + public Mono deleteBatch(MemoryId memoryId, List vectorIds) { + return Mono.empty(); + } + + @Override + public Flux search(MemoryId memoryId, String query, int topK) { + return Flux.empty(); + } + + @Override + public Flux search( + MemoryId memoryId, String query, int topK, Map filter) { + return Flux.empty(); + } + + @Override + public Mono> embed(String text) { + return Mono.just(List.of()); + } + + @Override + public Mono>> embedAll(List texts) { + return Mono.just(List.of()); + } + } +} From bb26cf41b29fb81a9266ea3ad439007e1ca74a17 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 25 May 2026 12:08:17 +0800 Subject: [PATCH 12/54] feat(rawdata-agent): add spring boot starter --- .../pom.xml | 53 ++++ .../AgentRawDataAutoConfiguration.java | 40 +++ .../autoconfigure/AgentRawDataProperties.java | 260 ++++++++++++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../AgentRawDataAutoConfigurationTest.java | 134 +++++++++ .../pom.xml | 1 + memind-server/pom.xml | 5 + .../server/MemindServerApplicationTest.java | 40 ++- 8 files changed, 533 insertions(+), 1 deletion(-) create mode 100644 memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/pom.xml create mode 100644 memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/AgentRawDataAutoConfiguration.java create mode 100644 memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/AgentRawDataProperties.java create mode 100644 memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/AgentRawDataAutoConfigurationTest.java diff --git a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/pom.xml b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/pom.xml new file mode 100644 index 00000000..0bce15f9 --- /dev/null +++ b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/pom.xml @@ -0,0 +1,53 @@ + + + + 4.0.0 + + com.openmemind.ai + memind-plugin-spring-boot-starters + ${revision} + ../pom.xml + + + memind-plugin-rawdata-agent-starter + Memind - Agent RawData Starter + + + + com.openmemind.ai + memind-plugin-rawdata-jackson-starter + ${revision} + + + com.openmemind.ai + memind-plugin-rawdata-agent + ${revision} + + + org.springframework.boot + spring-boot-autoconfigure + + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/AgentRawDataAutoConfiguration.java b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/AgentRawDataAutoConfiguration.java new file mode 100644 index 00000000..c3f541e1 --- /dev/null +++ b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/AgentRawDataAutoConfiguration.java @@ -0,0 +1,40 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.autoconfigure; + +import com.openmemind.ai.memory.core.plugin.RawDataPlugin; +import com.openmemind.ai.memory.plugin.rawdata.agent.plugin.AgentRawDataPlugin; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +@ConditionalOnClass(AgentRawDataPlugin.class) +@EnableConfigurationProperties(AgentRawDataProperties.class) +@ConditionalOnProperty( + prefix = "memind.rawdata.agent", + name = "enabled", + havingValue = "true", + matchIfMissing = true) +public class AgentRawDataAutoConfiguration { + + @Bean("agentRawDataPlugin") + @ConditionalOnMissingBean(name = "agentRawDataPlugin") + RawDataPlugin agentRawDataPlugin(AgentRawDataProperties properties) { + return new AgentRawDataPlugin(properties.toOptions()); + } +} diff --git a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/AgentRawDataProperties.java b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/AgentRawDataProperties.java new file mode 100644 index 00000000..5cb08199 --- /dev/null +++ b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/AgentRawDataProperties.java @@ -0,0 +1,260 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.autoconfigure; + +import com.openmemind.ai.memory.plugin.rawdata.agent.config.AgentChunkingOptions; +import com.openmemind.ai.memory.plugin.rawdata.agent.config.AgentExtractionOptions; +import com.openmemind.ai.memory.plugin.rawdata.agent.config.AgentPrivacyOptions; +import com.openmemind.ai.memory.plugin.rawdata.agent.config.AgentRawDataOptions; +import java.time.Duration; +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "memind.rawdata.agent") +public class AgentRawDataProperties { + + private static final AgentRawDataOptions DEFAULTS = AgentRawDataOptions.defaults(); + + private boolean enabled = true; + private final AgentChunkingProperties chunking = new AgentChunkingProperties(); + private final AgentExtractionProperties extraction = new AgentExtractionProperties(); + private final AgentPrivacyProperties privacy = new AgentPrivacyProperties(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public AgentChunkingProperties getChunking() { + return chunking; + } + + public AgentExtractionProperties getExtraction() { + return extraction; + } + + public AgentPrivacyProperties getPrivacy() { + return privacy; + } + + public AgentRawDataOptions toOptions() { + return new AgentRawDataOptions( + chunking.toOptions(), privacy.toOptions(), extraction.toOptions()); + } + + public static final class AgentChunkingProperties { + + private int targetEpisodeTokens = DEFAULTS.chunking().targetEpisodeTokens(); + private int hardMaxTokens = DEFAULTS.chunking().hardMaxTokens(); + private int maxEventsPerEpisode = DEFAULTS.chunking().maxEventsPerEpisode(); + private Duration maxEventGap = DEFAULTS.chunking().maxEventGap(); + + public int getTargetEpisodeTokens() { + return targetEpisodeTokens; + } + + public void setTargetEpisodeTokens(int targetEpisodeTokens) { + this.targetEpisodeTokens = targetEpisodeTokens; + } + + public int getHardMaxTokens() { + return hardMaxTokens; + } + + public void setHardMaxTokens(int hardMaxTokens) { + this.hardMaxTokens = hardMaxTokens; + } + + public int getMaxEventsPerEpisode() { + return maxEventsPerEpisode; + } + + public void setMaxEventsPerEpisode(int maxEventsPerEpisode) { + this.maxEventsPerEpisode = maxEventsPerEpisode; + } + + public Duration getMaxEventGap() { + return maxEventGap; + } + + public void setMaxEventGap(Duration maxEventGap) { + this.maxEventGap = maxEventGap; + } + + AgentChunkingOptions toOptions() { + return new AgentChunkingOptions( + targetEpisodeTokens, hardMaxTokens, maxEventsPerEpisode, maxEventGap); + } + } + + public static final class AgentExtractionProperties { + + private boolean extractTool = DEFAULTS.extraction().extractTool(); + private boolean extractResolution = DEFAULTS.extraction().extractResolution(); + private boolean extractPlaybook = DEFAULTS.extraction().extractPlaybook(); + private boolean extractDirective = DEFAULTS.extraction().extractDirective(); + private boolean extractOnEveryTool = DEFAULTS.extraction().extractOnEveryTool(); + private int minEventsForExtraction = DEFAULTS.extraction().minEventsForExtraction(); + private int minEventsForPlaybook = DEFAULTS.extraction().minEventsForPlaybook(); + private boolean requireSuccessForPlaybook = + DEFAULTS.extraction().requireSuccessForPlaybook(); + + public boolean isExtractTool() { + return extractTool; + } + + public void setExtractTool(boolean extractTool) { + this.extractTool = extractTool; + } + + public boolean isExtractResolution() { + return extractResolution; + } + + public void setExtractResolution(boolean extractResolution) { + this.extractResolution = extractResolution; + } + + public boolean isExtractPlaybook() { + return extractPlaybook; + } + + public void setExtractPlaybook(boolean extractPlaybook) { + this.extractPlaybook = extractPlaybook; + } + + public boolean isExtractDirective() { + return extractDirective; + } + + public void setExtractDirective(boolean extractDirective) { + this.extractDirective = extractDirective; + } + + public boolean isExtractOnEveryTool() { + return extractOnEveryTool; + } + + public void setExtractOnEveryTool(boolean extractOnEveryTool) { + this.extractOnEveryTool = extractOnEveryTool; + } + + public int getMinEventsForExtraction() { + return minEventsForExtraction; + } + + public void setMinEventsForExtraction(int minEventsForExtraction) { + this.minEventsForExtraction = minEventsForExtraction; + } + + public int getMinEventsForPlaybook() { + return minEventsForPlaybook; + } + + public void setMinEventsForPlaybook(int minEventsForPlaybook) { + this.minEventsForPlaybook = minEventsForPlaybook; + } + + public boolean isRequireSuccessForPlaybook() { + return requireSuccessForPlaybook; + } + + public void setRequireSuccessForPlaybook(boolean requireSuccessForPlaybook) { + this.requireSuccessForPlaybook = requireSuccessForPlaybook; + } + + AgentExtractionOptions toOptions() { + return new AgentExtractionOptions( + extractTool, + extractResolution, + extractPlaybook, + extractDirective, + extractOnEveryTool, + minEventsForExtraction, + minEventsForPlaybook, + requireSuccessForPlaybook); + } + } + + public static final class AgentPrivacyProperties { + + private boolean redactSecrets = DEFAULTS.privacy().redactSecrets(); + private int maxInputChars = DEFAULTS.privacy().maxInputChars(); + private int maxOutputChars = DEFAULTS.privacy().maxOutputChars(); + private boolean captureFileContent = DEFAULTS.privacy().captureFileContent(); + private List denyPathPatterns = DEFAULTS.privacy().denyPathPatterns(); + private List allowPathPatterns = DEFAULTS.privacy().allowPathPatterns(); + + public boolean isRedactSecrets() { + return redactSecrets; + } + + public void setRedactSecrets(boolean redactSecrets) { + this.redactSecrets = redactSecrets; + } + + public int getMaxInputChars() { + return maxInputChars; + } + + public void setMaxInputChars(int maxInputChars) { + this.maxInputChars = maxInputChars; + } + + public int getMaxOutputChars() { + return maxOutputChars; + } + + public void setMaxOutputChars(int maxOutputChars) { + this.maxOutputChars = maxOutputChars; + } + + public boolean isCaptureFileContent() { + return captureFileContent; + } + + public void setCaptureFileContent(boolean captureFileContent) { + this.captureFileContent = captureFileContent; + } + + public List getDenyPathPatterns() { + return denyPathPatterns; + } + + public void setDenyPathPatterns(List denyPathPatterns) { + this.denyPathPatterns = denyPathPatterns; + } + + public List getAllowPathPatterns() { + return allowPathPatterns; + } + + public void setAllowPathPatterns(List allowPathPatterns) { + this.allowPathPatterns = allowPathPatterns; + } + + AgentPrivacyOptions toOptions() { + return new AgentPrivacyOptions( + redactSecrets, + maxInputChars, + maxOutputChars, + captureFileContent, + denyPathPatterns, + allowPathPatterns); + } + } +} diff --git a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..0786d32b --- /dev/null +++ b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.openmemind.ai.memory.plugin.rawdata.agent.autoconfigure.AgentRawDataAutoConfiguration diff --git a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/AgentRawDataAutoConfigurationTest.java b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/AgentRawDataAutoConfigurationTest.java new file mode 100644 index 00000000..92392c99 --- /dev/null +++ b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/AgentRawDataAutoConfigurationTest.java @@ -0,0 +1,134 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.openmemind.ai.memory.core.extraction.rawdata.content.RawContent; +import com.openmemind.ai.memory.core.plugin.RawDataPlugin; +import com.openmemind.ai.memory.plugin.rawdata.agent.config.AgentRawDataOptions; +import com.openmemind.ai.memory.plugin.rawdata.agent.content.AgentTimelineContent; +import com.openmemind.ai.memory.plugin.rawdata.agent.plugin.AgentRawDataPlugin; +import com.openmemind.ai.memory.plugin.rawdata.jackson.autoconfigure.RawDataJacksonAutoConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.exc.InvalidTypeIdException; + +class AgentRawDataAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + RawDataJacksonAutoConfiguration.class, + AgentRawDataAutoConfiguration.class)); + + @Test + void registersAgentRawDataPluginAndAgentTimelineJsonBinding() { + contextRunner.run( + context -> { + assertThat(context).hasSingleBean(RawDataPlugin.class); + assertThat(context.getBean("agentRawDataPlugin")) + .isInstanceOf(AgentRawDataPlugin.class); + + ObjectMapper mapper = context.getBean(ObjectMapper.class); + assertThat( + mapper.readValue( + """ + { + "type": "agent_timeline", + "sourceClient": "claude-code", + "sessionId": "s", + "timelineId": "t", + "events": [] + } + """, + RawContent.class)) + .isInstanceOf(AgentTimelineContent.class); + }); + } + + @Test + void bindsAgentOptionsIntoPluginBean() { + contextRunner + .withPropertyValues( + "memind.rawdata.agent.chunking.target-episode-tokens=1600", + "memind.rawdata.agent.chunking.hard-max-tokens=3200", + "memind.rawdata.agent.chunking.max-events-per-episode=40", + "memind.rawdata.agent.chunking.max-event-gap=PT10M", + "memind.rawdata.agent.extraction.extract-playbook=false", + "memind.rawdata.agent.extraction.min-events-for-extraction=2", + "memind.rawdata.agent.privacy.max-input-chars=1200", + "memind.rawdata.agent.privacy.capture-file-content=true", + "memind.rawdata.agent.privacy.deny-path-patterns[0]=.env", + "memind.rawdata.agent.privacy.deny-path-patterns[1]=*.secret") + .run( + context -> { + var plugin = (AgentRawDataPlugin) context.getBean("agentRawDataPlugin"); + AgentRawDataOptions options = + readField(plugin, "options", AgentRawDataOptions.class); + + assertThat(options.chunking().targetEpisodeTokens()).isEqualTo(1600); + assertThat(options.chunking().hardMaxTokens()).isEqualTo(3200); + assertThat(options.chunking().maxEventsPerEpisode()).isEqualTo(40); + assertThat(options.chunking().maxEventGap()).hasMinutes(10); + assertThat(options.extraction().extractPlaybook()).isFalse(); + assertThat(options.extraction().minEventsForExtraction()).isEqualTo(2); + assertThat(options.privacy().maxInputChars()).isEqualTo(1200); + assertThat(options.privacy().captureFileContent()).isTrue(); + assertThat(options.privacy().denyPathPatterns()) + .containsExactly(".env", "*.secret"); + }); + } + + @Test + void disablingStarterRemovesPluginAndAgentTimelineJsonBinding() { + contextRunner + .withPropertyValues("memind.rawdata.agent.enabled=false") + .run( + context -> { + assertThat(context).doesNotHaveBean(RawDataPlugin.class); + + assertThatThrownBy( + () -> + context.getBean(ObjectMapper.class) + .readValue( + """ + { + "type": "agent_timeline", + "sourceClient": "claude-code", + "sessionId": "s", + "timelineId": "t", + "events": [] + } + """, + RawContent.class)) + .isInstanceOf(InvalidTypeIdException.class) + .hasMessageContaining("agent_timeline"); + }); + } + + private static T readField(Object target, String name, Class type) { + try { + var field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + return type.cast(field.get(target)); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } +} diff --git a/memind-plugins/memind-plugin-spring-boot-starters/pom.xml b/memind-plugins/memind-plugin-spring-boot-starters/pom.xml index 01f635c1..50008736 100644 --- a/memind-plugins/memind-plugin-spring-boot-starters/pom.xml +++ b/memind-plugins/memind-plugin-spring-boot-starters/pom.xml @@ -34,6 +34,7 @@ memind-plugin-jdbc-starter memind-plugin-mybatis-plus-starter memind-plugin-rawdata-audio-starter + memind-plugin-rawdata-agent-starter memind-plugin-rawdata-document-starter memind-plugin-rawdata-image-starter memind-plugin-rawdata-jackson-starter diff --git a/memind-server/pom.xml b/memind-server/pom.xml index 7fce1d02..0cd95521 100644 --- a/memind-server/pom.xml +++ b/memind-server/pom.xml @@ -70,6 +70,11 @@ memind-plugin-rawdata-toolcall-starter ${revision} + + com.openmemind.ai + memind-plugin-rawdata-agent-starter + ${revision} + org.springframework.boot spring-boot-starter-web diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerApplicationTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerApplicationTest.java index 99280e97..6da542b7 100644 --- a/memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerApplicationTest.java +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerApplicationTest.java @@ -98,6 +98,9 @@ void contextLoadsWithRuntimeDependencies() { assertThat(applicationContext.containsBean("toolCallRawDataPlugin")).isTrue(); assertThat(applicationContext.getBean("toolCallRawDataPlugin")) .isInstanceOf(RawDataPlugin.class); + assertThat(applicationContext.containsBean("agentRawDataPlugin")).isTrue(); + assertThat(applicationContext.getBean("agentRawDataPlugin")) + .isInstanceOf(RawDataPlugin.class); try (var lease = runtimeManager.acquire()) { assertThat(lease.handle().memory()).isNotNull(); @@ -124,7 +127,7 @@ void commitApiAcceptsRequestWhenRuntimeIsAvailable() throws Exception { @Test void - extractApiAcceptsPluginOwnedImageAudioDocumentAndToolCallRawContentViaApplicationObjectMapper() + extractApiAcceptsPluginOwnedImageAudioDocumentToolCallAndAgentTimelineRawContentViaApplicationObjectMapper() throws Exception { mockMvc.perform( post("/open/v1/memory/async/extract") @@ -244,5 +247,40 @@ void commitApiAcceptsRequestWhenRuntimeIsAvailable() throws Exception { """)) .andExpect(status().isAccepted()) .andExpect(jsonPath("$.data.status").value("accepted")); + + mockMvc.perform( + post("/open/v1/memory/async/extract") + .contentType(APPLICATION_JSON) + .content( + """ + { + "userId": "u1", + "agentId": "a1", + "rawContent": { + "type": "agent_timeline", + "sourceClient": "claude-code", + "sessionId": "session-1", + "timelineId": "timeline-1", + "events": [ + { + "seq": 1, + "kind": "USER_PROMPT", + "occurredAt": "2026-04-12T00:00:00Z", + "text": "Fix failing tests" + }, + { + "seq": 2, + "kind": "COMMAND", + "occurredAt": "2026-04-12T00:01:00Z", + "command": "mvn test", + "exitCode": 0, + "status": "SUCCESS" + } + ] + } + } + """)) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.data.status").value("accepted")); } } From fbe331883a9c74ef1a2648efe69bbf1dc781f83c Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 25 May 2026 13:52:37 +0800 Subject: [PATCH 13/54] test(jdbc): cover agent timeline json codec --- .../memind-plugin-jdbc-core/pom.xml | 6 ++ .../jdbc/internal/support/JsonCodecTest.java | 67 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-core/pom.xml b/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-core/pom.xml index 6b073ca7..9ee665d1 100644 --- a/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-core/pom.xml +++ b/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-core/pom.xml @@ -76,5 +76,11 @@ ${revision} test + + com.openmemind.ai + memind-plugin-rawdata-agent + ${revision} + test + diff --git a/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-core/src/test/java/com/openmemind/ai/memory/plugin/jdbc/internal/support/JsonCodecTest.java b/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-core/src/test/java/com/openmemind/ai/memory/plugin/jdbc/internal/support/JsonCodecTest.java index 8239a5c1..8bbb1579 100644 --- a/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-core/src/test/java/com/openmemind/ai/memory/plugin/jdbc/internal/support/JsonCodecTest.java +++ b/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-core/src/test/java/com/openmemind/ai/memory/plugin/jdbc/internal/support/JsonCodecTest.java @@ -21,6 +21,12 @@ import com.openmemind.ai.memory.core.extraction.rawdata.content.ConversationContent; import com.openmemind.ai.memory.core.extraction.rawdata.content.RawContent; import com.openmemind.ai.memory.core.extraction.rawdata.content.conversation.message.Message; +import com.openmemind.ai.memory.plugin.rawdata.agent.AgentRawContentTypeRegistrar; +import com.openmemind.ai.memory.plugin.rawdata.agent.content.AgentTimelineContent; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEvent; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventKind; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventStatus; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentProject; import com.openmemind.ai.memory.plugin.rawdata.toolcall.ToolCallRawContentTypeRegistrar; import com.openmemind.ai.memory.plugin.rawdata.toolcall.content.ToolCallContent; import com.openmemind.ai.memory.plugin.rawdata.toolcall.model.ToolCallRecord; @@ -110,6 +116,67 @@ void codecRoundTripsToolCallWhenPluginSubtypeIsExplicitlyRegistered() { .containsExactly("search", "SUCCESS"); } + @Test + void codecRoundTripsAgentTimelineWhenPluginSubtypeIsExplicitlyRegistered() { + ObjectMapper mapper = JsonCodec.createDefaultObjectMapper(); + mapper = RawContentJackson.registerCoreSubtypes(mapper); + mapper = + RawContentJackson.registerPluginSubtypes( + mapper, List.of(new AgentRawContentTypeRegistrar())); + JsonCodec codec = new JsonCodec(mapper); + RawContent payload = sampleAgentTimelineContent(); + + String json = codec.toJson(payload); + RawContent restored = codec.fromJson(json, RawContent.class); + + assertThat(restored).isInstanceOf(AgentTimelineContent.class); + assertThat(((AgentTimelineContent) restored).events()) + .extracting(AgentEvent::command) + .contains("mvn test"); + } + + private static AgentTimelineContent sampleAgentTimelineContent() { + return new AgentTimelineContent( + "claude-code", + "1.0", + "session-1", + "timeline-1", + new AgentProject("memind", "/repo/memind", null, Map.of()), + List.of( + new AgentEvent( + "e1", + 1, + AgentEventKind.USER_PROMPT, + Instant.parse("2026-05-24T10:00:00Z"), + "Fix failing tests", + null, + null, + null, + null, + null, + null, + null, + null, + null, + Map.of()), + new AgentEvent( + "e2", + 2, + AgentEventKind.COMMAND, + Instant.parse("2026-05-24T10:01:00Z"), + null, + "Bash", + null, + "build passed", + AgentEventStatus.SUCCESS, + 1200L, + null, + null, + "mvn test", + 0, + Map.of()))); + } + record SamplePayload(String name, Instant createdAt) {} private static final class TestRawContent extends RawContent { From 2979afb7f38ac762cdcf3bfc74be695154cd8668 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 25 May 2026 14:11:03 +0800 Subject: [PATCH 14/54] feat(clients): add agent timeline helpers --- .../common/RawContentSerializerTest.java | 15 ++++++++ .../src/memind/resources/async_memory.py | 18 ++++++++- .../python/src/memind/resources/memory.py | 18 ++++++++- .../python/tests/test_async_client.py | 36 ++++++++++++++++++ memind-clients/python/tests/test_client.py | 35 +++++++++++++++++ memind-clients/typescript/src/index.ts | 2 + .../typescript/src/types/message.ts | 31 ++++++++++++++- .../typescript/tests/client.test.ts | 38 +++++++++++++++++++ 8 files changed, 190 insertions(+), 3 deletions(-) diff --git a/memind-clients/java/memind-client/src/test/java/com/openmemind/ai/client/model/common/RawContentSerializerTest.java b/memind-clients/java/memind-client/src/test/java/com/openmemind/ai/client/model/common/RawContentSerializerTest.java index c8acdb10..06cd369c 100644 --- a/memind-clients/java/memind-client/src/test/java/com/openmemind/ai/client/model/common/RawContentSerializerTest.java +++ b/memind-clients/java/memind-client/src/test/java/com/openmemind/ai/client/model/common/RawContentSerializerTest.java @@ -54,4 +54,19 @@ void mapRawContent_serializesPropertiesFlat() throws Exception { assertThat(json).contains("\"fileName\":\"test.pdf\""); assertThat(json).contains("\"mimeType\":\"application/pdf\""); } + + @Test + void mapRawContent_serializesAgentTimelinePropertiesFlat() throws Exception { + MapRawContent content = + MapRawContent.of( + "agent_timeline", + Map.of("sessionId", "s", "timelineId", "t", "events", List.of())); + + String json = mapper.writeValueAsString(content); + + assertThat(json).contains("\"type\":\"agent_timeline\""); + assertThat(json).contains("\"sessionId\":\"s\""); + assertThat(json).contains("\"timelineId\":\"t\""); + assertThat(json).contains("\"events\":[]"); + } } diff --git a/memind-clients/python/src/memind/resources/async_memory.py b/memind-clients/python/src/memind/resources/async_memory.py index f8237f8a..7fde59c6 100644 --- a/memind-clients/python/src/memind/resources/async_memory.py +++ b/memind-clients/python/src/memind/resources/async_memory.py @@ -14,7 +14,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar from memind.types.common import Strategy from memind.types.memory import ( @@ -55,6 +55,22 @@ async def extract( assert result is not None return result + async def extract_agent_timeline( + self, + *, + user_id: str, + agent_id: str, + timeline: dict[str, Any], + source_client: str | None = None, + ) -> ExtractMemoryResponse: + raw_content = {"type": "agent_timeline", **timeline} + return await self.extract( + user_id=user_id, + agent_id=agent_id, + raw_content=raw_content, + source_client=source_client, + ) + async def add_message( self, request: AddMessageRequest | None = None, diff --git a/memind-clients/python/src/memind/resources/memory.py b/memind-clients/python/src/memind/resources/memory.py index f5f9aa6e..9fb605fa 100644 --- a/memind-clients/python/src/memind/resources/memory.py +++ b/memind-clients/python/src/memind/resources/memory.py @@ -14,7 +14,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar from memind.types.common import Strategy from memind.types.memory import ( @@ -55,6 +55,22 @@ def extract( assert result is not None return result + def extract_agent_timeline( + self, + *, + user_id: str, + agent_id: str, + timeline: dict[str, Any], + source_client: str | None = None, + ) -> ExtractMemoryResponse: + raw_content = {"type": "agent_timeline", **timeline} + return self.extract( + user_id=user_id, + agent_id=agent_id, + raw_content=raw_content, + source_client=source_client, + ) + def add_message( self, request: AddMessageRequest | None = None, diff --git a/memind-clients/python/tests/test_async_client.py b/memind-clients/python/tests/test_async_client.py index 343e1b93..cdeb27fb 100644 --- a/memind-clients/python/tests/test_async_client.py +++ b/memind-clients/python/tests/test_async_client.py @@ -87,6 +87,42 @@ async def test_async_memory_methods_send_payloads(httpx_mock) -> None: assert b'"userId":"u1"' in requests[2].content +@pytest.mark.asyncio +async def test_async_extract_agent_timeline_sends_map_raw_content(httpx_mock) -> None: + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/sync/extract", + json={ + "data": { + "status": "SUCCESS", + "rawDataIds": ["rd-1"], + "itemIds": [], + "insightIds": [], + "insightPending": False, + }, + }, + ) + + client = AsyncMemindClient(base_url="https://api.example.test") + await client.memory.extract_agent_timeline( + user_id="u1", + agent_id="a1", + timeline={ + "sourceClient": "claude-code", + "sessionId": "s", + "timelineId": "t", + "events": [], + }, + source_client="claude-code", + ) + await client.close() + + content = httpx_mock.get_request().content + assert b'"rawContent":{"type":"agent_timeline"' in content + assert b'"sessionId":"s"' in content + assert b'"sourceClient":"claude-code"' in content + + @pytest.mark.asyncio async def test_async_retrieve_returns_response(httpx_mock) -> None: httpx_mock.add_response( diff --git a/memind-clients/python/tests/test_client.py b/memind-clients/python/tests/test_client.py index 8bdefc89..566e0098 100644 --- a/memind-clients/python/tests/test_client.py +++ b/memind-clients/python/tests/test_client.py @@ -89,6 +89,41 @@ def test_extract_sends_raw_content(httpx_mock) -> None: client.close() +def test_extract_agent_timeline_sends_map_raw_content(httpx_mock) -> None: + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/sync/extract", + json={ + "data": { + "status": "SUCCESS", + "rawDataIds": ["rd-1"], + "itemIds": [], + "insightIds": [], + "insightPending": False, + }, + }, + ) + + client = MemindClient(base_url="https://api.example.test") + client.memory.extract_agent_timeline( + user_id="u1", + agent_id="a1", + timeline={ + "sourceClient": "claude-code", + "sessionId": "s", + "timelineId": "t", + "events": [], + }, + source_client="claude-code", + ) + + content = httpx_mock.get_request().content + assert b'"rawContent":{"type":"agent_timeline"' in content + assert b'"sessionId":"s"' in content + assert b'"sourceClient":"claude-code"' in content + client.close() + + def test_commit_sends_payload(httpx_mock) -> None: httpx_mock.add_response( method="POST", diff --git a/memind-clients/typescript/src/index.ts b/memind-clients/typescript/src/index.ts index 63f873d4..8f038b96 100644 --- a/memind-clients/typescript/src/index.ts +++ b/memind-clients/typescript/src/index.ts @@ -20,6 +20,8 @@ export type { ApiError, ApiResult, RequestOptions } from './types/common.js' export type { HealthResponse } from './types/health.js' export { Message, RawContent } from './types/message.js' export type { + AgentTimelineContent, + AgentTimelineEvent, ContentBlock, ConversationContent, JsonObjectRawContent, diff --git a/memind-clients/typescript/src/types/message.ts b/memind-clients/typescript/src/types/message.ts index 67fb424f..710a6e5f 100644 --- a/memind-clients/typescript/src/types/message.ts +++ b/memind-clients/typescript/src/types/message.ts @@ -76,7 +76,36 @@ export type JsonObjectRawContent = { [key: string]: JsonValue } -export type RawContentValue = ConversationContent | JsonObjectRawContent +export type AgentTimelineEvent = { + id?: string + seq?: number + kind?: string + occurredAt?: string + text?: string + toolName?: string + input?: JsonValue + output?: JsonValue + status?: string + durationMs?: number + path?: string + operation?: string + command?: string + exitCode?: number + metadata?: Record +} + +export type AgentTimelineContent = { + type: 'agent_timeline' + sourceClient?: string + sourceVersion?: string + sessionId: string + timelineId: string + events: AgentTimelineEvent[] + project?: Record + metadata?: Record +} + +export type RawContentValue = ConversationContent | AgentTimelineContent | JsonObjectRawContent export const RawContent = { conversation(messages: MessageValue[]): ConversationContent { diff --git a/memind-clients/typescript/tests/client.test.ts b/memind-clients/typescript/tests/client.test.ts index c783c432..8fed0b5b 100644 --- a/memind-clients/typescript/tests/client.test.ts +++ b/memind-clients/typescript/tests/client.test.ts @@ -14,6 +14,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { MemindClient } from '../src/client.js' +import type { AgentTimelineContent, RawContentValue } from '../src/types/message.js' type TestProcess = { env?: Record @@ -189,5 +190,42 @@ describe('MemindClient', () => { }), ) }) + + it('preserves agent timeline raw content in extract requests', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ + data: { status: 'SUCCESS', rawDataIds: ['rd-1'], itemIds: [], insightIds: [] }, + }), + }) + const client = new MemindClient({ + baseUrl: 'http://localhost:8366', + fetch: mockFetch, + }) + const raw: RawContentValue = { + type: 'agent_timeline', + sourceClient: 'claude-code', + sessionId: 's', + timelineId: 't', + events: [], + } satisfies AgentTimelineContent + + await client.memory.extract({ + userId: 'u1', + agentId: 'a1', + rawContent: raw, + sourceClient: 'claude-code', + }) + + const body = JSON.parse(String(mockFetch.mock.calls[0]?.[1]?.body)) + expect(body.rawContent).toEqual({ + type: 'agent_timeline', + sourceClient: 'claude-code', + sessionId: 's', + timelineId: 't', + events: [], + }) + }) }) }) From cc2cc261100da3272229e3e7efcbdd8f185a911c Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 25 May 2026 14:24:00 +0800 Subject: [PATCH 15/54] feat(claude-code): capture agent timelines --- .../claude-code/hooks/hooks.json | 24 +++ .../claude-code/scripts/ingest.py | 61 ++++++ .../claude-code/scripts/lib/agent_timeline.py | 171 ++++++++++++++++ .../claude-code/scripts/lib/config.py | 2 + .../claude-code/scripts/lib/state.py | 33 +++ .../claude-code/scripts/post_tool_use.py | 48 +++++ .../claude-code/scripts/pre_tool_use.py | 48 +++++ .../claude-code/scripts/session_start.py | 3 + memind-integrations/claude-code/settings.json | 1 + .../claude-code/tests/test_agent_timeline.py | 94 +++++++++ .../claude-code/tests/test_config.py | 3 + .../claude-code/tests/test_hooks.py | 191 ++++++++++++++++++ .../claude-code/tests/test_manifest.py | 13 +- .../claude-code/tests/test_state.py | 25 +++ 14 files changed, 716 insertions(+), 1 deletion(-) create mode 100644 memind-integrations/claude-code/scripts/lib/agent_timeline.py create mode 100644 memind-integrations/claude-code/scripts/post_tool_use.py create mode 100644 memind-integrations/claude-code/scripts/pre_tool_use.py create mode 100644 memind-integrations/claude-code/tests/test_agent_timeline.py diff --git a/memind-integrations/claude-code/hooks/hooks.json b/memind-integrations/claude-code/hooks/hooks.json index 1bc0e40e..a9fd326c 100644 --- a/memind-integrations/claude-code/hooks/hooks.json +++ b/memind-integrations/claude-code/hooks/hooks.json @@ -22,6 +22,30 @@ ] } ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/pre_tool_use.py\"", + "timeout": 5, + "async": true + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/post_tool_use.py\"", + "timeout": 5, + "async": true + } + ] + } + ], "PreCompact": [ { "hooks": [ diff --git a/memind-integrations/claude-code/scripts/ingest.py b/memind-integrations/claude-code/scripts/ingest.py index cd04271b..3428e00b 100644 --- a/memind-integrations/claude-code/scripts/ingest.py +++ b/memind-integrations/claude-code/scripts/ingest.py @@ -22,6 +22,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from lib.client import MemindClient +from lib.agent_timeline import build_timeline_payload from lib.config import load_config from lib.content import extract_messages from lib.identity import resolve_identity @@ -31,10 +32,16 @@ def state_root(): + override = os.environ.get("MEMIND_CLAUDE_STATE_ROOT") + if override: + return Path(override) return Path.home() / ".memind" / "claude-code" / "state" def retry_root(): + override = os.environ.get("MEMIND_CLAUDE_RETRY_ROOT") + if override: + return Path(override) return Path.home() / ".memind" / "claude-code" / "retry" @@ -65,6 +72,22 @@ def _spool_extract(retry_spool, identity, source_client, session_id, messages): ) +def _spool_agent_timeline(retry_spool, identity, source_client, session_id, events, raw_content): + if retry_spool is None or not events: + return + retry_spool.enqueue( + { + "kind": "extract", + "userId": identity["userId"], + "agentId": identity["agentId"], + "sourceClient": source_client, + "sessionId": session_id, + "eventIds": [event["id"] for event in events if event.get("id")], + "rawContent": raw_content, + } + ) + + async def ingest_messages_async(config, hook_input, commit=False, max_messages=None): identity = resolve_identity(config, hook_input) client = MemindClient(config["memindApiUrl"], config.get("memindApiToken"), timeout=10, max_retries=0) @@ -99,6 +122,44 @@ async def ingest_messages_async(config, hook_input, commit=False, max_messages=N else: _spool_extract(retry_spool, identity, source_client, session_id, selected) state.mark_submitted(submitted) + agent_events = state.agent_events() if config.get("autoIngestAgentTimeline", True) else [] + if agent_events: + timeline_payload = build_timeline_payload( + config, + identity, + session_id, + agent_events, + hook_input, + ) + try: + response = await client.extract( + identity["userId"], + identity["agentId"], + timeline_payload, + source_client, + ) + except Exception: + _spool_agent_timeline( + retry_spool, + identity, + source_client, + session_id, + agent_events, + timeline_payload, + ) + else: + status = getattr(response, "status", None) + if status == "SUCCESS": + state.clear_agent_events([event["id"] for event in agent_events if event.get("id")]) + else: + _spool_agent_timeline( + retry_spool, + identity, + source_client, + session_id, + agent_events, + timeline_payload, + ) committed = False return {"submitted": len(submitted), "committed": committed} diff --git a/memind-integrations/claude-code/scripts/lib/agent_timeline.py b/memind-integrations/claude-code/scripts/lib/agent_timeline.py new file mode 100644 index 00000000..376769c5 --- /dev/null +++ b/memind-integrations/claude-code/scripts/lib/agent_timeline.py @@ -0,0 +1,171 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import hashlib +import json +import re +from pathlib import Path + + +MAX_TEXT_CHARS = 4000 + +SECRET_PATTERNS = [ + ("openai_key", re.compile(r"sk-[A-Za-z0-9_-]{8,}")), + ("bearer_token", re.compile(r"Bearer\s+[A-Za-z0-9._~+/=-]+", re.IGNORECASE)), + ("private_key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.DOTALL)), +] + + +def redact_text(text): + redacted = str(text) + kinds = [] + for kind, pattern in SECRET_PATTERNS: + if pattern.search(redacted): + redacted = pattern.sub(f"[REDACTED:{kind}]", redacted) + kinds.append(kind) + if len(redacted) > MAX_TEXT_CHARS: + redacted = redacted[:MAX_TEXT_CHARS] + kinds.append("truncated") + return redacted, sorted(set(kinds)) + + +def _redact_value(value): + if value is None or isinstance(value, (bool, int, float)): + return value, [] + if isinstance(value, str): + return redact_text(value) + if isinstance(value, list): + result = [] + kinds = [] + for item in value: + redacted, item_kinds = _redact_value(item) + result.append(redacted) + kinds.extend(item_kinds) + return result, sorted(set(kinds)) + if isinstance(value, dict): + result = {} + kinds = [] + for key, item in value.items(): + redacted, item_kinds = _redact_value(item) + result[key] = redacted + kinds.extend(item_kinds) + return result, sorted(set(kinds)) + redacted, kinds = redact_text(value) + return redacted, kinds + + +def _json_text(value): + if value is None or isinstance(value, str): + return value + return json.dumps(value, ensure_ascii=False, sort_keys=True) + + +def event_id(source_client, session_id, seq, hook_input): + hook_name = hook_input.get("hook_event_name") or "" + tool_name = hook_input.get("tool_name") or "" + timestamp = hook_input.get("timestamp") or "" + stable = json.dumps( + { + "sourceClient": source_client, + "sessionId": session_id, + "seq": seq, + "hook": hook_name, + "tool": tool_name, + "timestamp": timestamp, + }, + sort_keys=True, + ) + return hashlib.sha256(stable.encode("utf-8")).hexdigest() + + +def normalize_hook_event(hook_input, seq): + source_client = hook_input.get("source_client") or "claude-code" + session_id = hook_input.get("session_id") or "unknown-session" + tool_name = hook_input.get("tool_name") + tool_input = hook_input.get("tool_input") or {} + tool_response = hook_input.get("tool_response") or {} + exit_code = tool_response.get("exit_code") + redaction_kinds = [] + + event = { + "id": event_id(source_client, session_id, seq, hook_input), + "seq": seq, + "kind": _event_kind(tool_name), + "occurredAt": hook_input.get("timestamp"), + "toolName": tool_name, + } + if tool_name == "Bash" and isinstance(tool_input, dict): + command, kinds = redact_text(tool_input.get("command") or "") + event["command"] = command + redaction_kinds.extend(kinds) + else: + redacted_input, kinds = _redact_value(tool_input) + event["input"] = _json_text(redacted_input) + redaction_kinds.extend(kinds) + + if exit_code is not None: + event["exitCode"] = exit_code + event["status"] = "success" if exit_code == 0 else "failed" + else: + event["status"] = "success" if hook_input.get("hook_event_name") == "PostToolUse" else "started" + + output = { + key: value + for key, value in tool_response.items() + if key not in {"exit_code", "exitCode"} and value is not None + } + if output: + redacted_output, kinds = _redact_value(output) + event["output"] = _json_text(redacted_output) + redaction_kinds.extend(kinds) + + metadata = { + "hookEventName": hook_input.get("hook_event_name"), + } + if redaction_kinds: + metadata["redacted"] = True + metadata["redactionKinds"] = sorted(set(redaction_kinds)) + event["metadata"] = {key: value for key, value in metadata.items() if value is not None} + return {key: value for key, value in event.items() if value is not None} + + +def _event_kind(tool_name): + if tool_name == "Bash": + return "command" + return "tool" + + +def append_event(state, event): + state.append_agent_event(event) + + +def build_timeline_payload(config, identity, session_id, events, hook_input): + source_client = config.get("sourceClient") or "claude-code" + cwd = hook_input.get("cwd") + first_seq = events[0].get("seq") if events else 0 + last_seq = events[-1].get("seq") if events else 0 + payload = { + "type": "agent_timeline", + "sourceClient": source_client, + "sessionId": session_id, + "timelineId": f"{session_id}-agent-{first_seq}-{last_seq}", + "events": list(events), + "metadata": { + "userId": identity.get("userId"), + "agentId": identity.get("agentId"), + }, + } + if cwd: + payload["project"] = {"cwd": str(Path(cwd))} + return payload diff --git a/memind-integrations/claude-code/scripts/lib/config.py b/memind-integrations/claude-code/scripts/lib/config.py index 8286ea1f..e8908dc8 100644 --- a/memind-integrations/claude-code/scripts/lib/config.py +++ b/memind-integrations/claude-code/scripts/lib/config.py @@ -25,6 +25,7 @@ "sourceClient": "claude-code", "autoRetrieve": True, "autoIngest": True, + "autoIngestAgentTimeline": True, "retrieveStrategy": "SIMPLE", "retrieveMaxEntries": 8, "retrieveMaxChars": 6000, @@ -52,6 +53,7 @@ "MEMIND_SOURCE_CLIENT": ("sourceClient", str), "MEMIND_AUTO_RETRIEVE": ("autoRetrieve", "bool"), "MEMIND_AUTO_INGEST": ("autoIngest", "bool"), + "MEMIND_AUTO_INGEST_AGENT_TIMELINE": ("autoIngestAgentTimeline", "bool"), "MEMIND_RETRIEVE_STRATEGY": ("retrieveStrategy", str), "MEMIND_RETRIEVE_CONTEXT_TURNS": ("retrieveContextTurns", "int_allow_zero"), "MEMIND_INGESTION_MODE": ("ingestionMode", str), diff --git a/memind-integrations/claude-code/scripts/lib/state.py b/memind-integrations/claude-code/scripts/lib/state.py index d06614d4..4c8533a6 100644 --- a/memind-integrations/claude-code/scripts/lib/state.py +++ b/memind-integrations/claude-code/scripts/lib/state.py @@ -20,6 +20,7 @@ from pathlib import Path SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9_.-]+") +MAX_AGENT_EVENTS = 500 def _safe_session_id(session_id): @@ -30,6 +31,8 @@ class SessionState: def __init__(self, data): self.data = data self.data.setdefault("submitted", []) + self.data.setdefault("agentEvents", []) + self.data.setdefault("nextAgentSeq", 1) def is_submitted(self, fingerprint): return fingerprint in set(self.data.get("submitted", [])) @@ -40,6 +43,36 @@ def mark_submitted(self, fingerprints): self.data["submitted"] = sorted(submitted) self.data["updatedAt"] = time.time() + def append_agent_event(self, event): + events = list(self.data.get("agentEvents", [])) + event_id = event.get("id") + if event_id and any(existing.get("id") == event_id for existing in events): + return + events.append(event) + if len(events) > MAX_AGENT_EVENTS: + events = events[-MAX_AGENT_EVENTS:] + self.data["agentEventsTruncated"] = True + self.data["agentEvents"] = events + self.data["updatedAt"] = time.time() + + def agent_events(self): + return list(self.data.get("agentEvents", [])) + + def clear_agent_events(self, event_ids): + event_ids = set(event_ids or []) + if not event_ids: + return + self.data["agentEvents"] = [ + event for event in self.data.get("agentEvents", []) if event.get("id") not in event_ids + ] + self.data["updatedAt"] = time.time() + + def next_agent_seq(self): + seq = int(self.data.get("nextAgentSeq", 1)) + self.data["nextAgentSeq"] = seq + 1 + self.data["updatedAt"] = time.time() + return seq + class SessionStateStore: def __init__(self, root): diff --git a/memind-integrations/claude-code/scripts/post_tool_use.py b/memind-integrations/claude-code/scripts/post_tool_use.py new file mode 100644 index 00000000..f2a2ab25 --- /dev/null +++ b/memind-integrations/claude-code/scripts/post_tool_use.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from ingest import state_root +from lib.agent_timeline import normalize_hook_event +from lib.config import load_config +from lib.logging_utils import debug_log +from lib.state import SessionStateStore + + +def main(): + try: + hook_input = json.loads(sys.stdin.read() or "{}") + config = load_config() + session_id = hook_input.get("session_id") or "unknown-session" + hook_input["source_client"] = config.get("sourceClient") or "claude-code" + with SessionStateStore(state_root()).locked(session_id) as state: + seq = state.next_agent_seq() + event = normalize_hook_event(hook_input, seq) + state.append_agent_event(event) + except Exception as exc: + try: + debug_log(load_config(), "post_tool_use_failed", {"error": str(exc)}) + except Exception: + pass + print(json.dumps({"continue": True})) + + +if __name__ == "__main__": + main() diff --git a/memind-integrations/claude-code/scripts/pre_tool_use.py b/memind-integrations/claude-code/scripts/pre_tool_use.py new file mode 100644 index 00000000..9bc7e051 --- /dev/null +++ b/memind-integrations/claude-code/scripts/pre_tool_use.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from ingest import state_root +from lib.agent_timeline import normalize_hook_event +from lib.config import load_config +from lib.logging_utils import debug_log +from lib.state import SessionStateStore + + +def main(): + try: + hook_input = json.loads(sys.stdin.read() or "{}") + config = load_config() + session_id = hook_input.get("session_id") or "unknown-session" + hook_input["source_client"] = config.get("sourceClient") or "claude-code" + with SessionStateStore(state_root()).locked(session_id) as state: + seq = state.next_agent_seq() + event = normalize_hook_event(hook_input, seq) + state.append_agent_event(event) + except Exception as exc: + try: + debug_log(load_config(), "pre_tool_use_failed", {"error": str(exc)}) + except Exception: + pass + print(json.dumps({"continue": True})) + + +if __name__ == "__main__": + main() diff --git a/memind-integrations/claude-code/scripts/session_start.py b/memind-integrations/claude-code/scripts/session_start.py index d885d895..902427ea 100644 --- a/memind-integrations/claude-code/scripts/session_start.py +++ b/memind-integrations/claude-code/scripts/session_start.py @@ -66,6 +66,9 @@ async def _run_session_start_async(config): if payload.get("sessionId") and payload.get("fingerprints"): with SessionStateStore(state_root()).locked(payload["sessionId"]) as state: state.mark_submitted(payload["fingerprints"]) + if payload.get("sessionId") and payload.get("eventIds"): + with SessionStateStore(state_root()).locked(payload["sessionId"]) as state: + state.clear_agent_events(payload["eventIds"]) spool.complete(claimed) elif payload.get("kind") == "add-message": await replay_client.add_message( diff --git a/memind-integrations/claude-code/settings.json b/memind-integrations/claude-code/settings.json index f1db3037..b7e46339 100644 --- a/memind-integrations/claude-code/settings.json +++ b/memind-integrations/claude-code/settings.json @@ -7,6 +7,7 @@ "sourceClient": "claude-code", "autoRetrieve": true, "autoIngest": true, + "autoIngestAgentTimeline": true, "retrieveStrategy": "SIMPLE", "retrieveMaxEntries": 8, "retrieveMaxChars": 6000, diff --git a/memind-integrations/claude-code/tests/test_agent_timeline.py b/memind-integrations/claude-code/tests/test_agent_timeline.py new file mode 100644 index 00000000..540c2010 --- /dev/null +++ b/memind-integrations/claude-code/tests/test_agent_timeline.py @@ -0,0 +1,94 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import json +import unittest + +from scripts.lib.agent_timeline import build_timeline_payload, normalize_hook_event + + +class AgentTimelineTest(unittest.TestCase): + def test_normalizes_post_tool_use_to_command_event(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "Bash", + "tool_input": {"command": "npm test payment"}, + "tool_response": {"exit_code": 1, "stdout": "rounding mismatch"}, + "timestamp": "2026-05-24T10:00:00Z", + }, + seq=1, + ) + + self.assertEqual(event["kind"], "command") + self.assertEqual(event["seq"], 1) + self.assertEqual(event["command"], "npm test payment") + self.assertEqual(event["status"], "failed") + self.assertEqual(event["exitCode"], 1) + self.assertEqual(event["output"], '{"stdout": "rounding mismatch"}') + + def test_redacts_secret_fields_before_spool(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "Bash", + "tool_input": {"command": "echo sk-test-secret"}, + "tool_response": { + "exit_code": 0, + "stdout": "Authorization: Bearer abc.def.ghi\nsk-live-secret", + }, + "timestamp": "2026-05-24T10:00:00Z", + }, + seq=1, + ) + + serialized = json.dumps(event) + self.assertNotIn("sk-test-secret", serialized) + self.assertNotIn("sk-live-secret", serialized) + self.assertNotIn("abc.def.ghi", serialized) + self.assertIn("[REDACTED", serialized) + + def test_builds_agent_timeline_payload(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "Bash", + "tool_input": {"command": "npm test payment"}, + "tool_response": {"exit_code": 0}, + "timestamp": "2026-05-24T10:00:00Z", + }, + seq=1, + ) + + payload = build_timeline_payload( + config={"sourceClient": "claude-code"}, + identity={"userId": "u", "agentId": "a"}, + session_id="s", + events=[event], + hook_input={"cwd": "/tmp/project"}, + ) + + self.assertEqual(payload["type"], "agent_timeline") + self.assertEqual(payload["sourceClient"], "claude-code") + self.assertEqual(payload["sessionId"], "s") + self.assertEqual(payload["timelineId"], "s-agent-1-1") + self.assertEqual(payload["events"][0]["seq"], 1) + self.assertEqual(payload["project"]["cwd"], "/tmp/project") + + +if __name__ == "__main__": + unittest.main() diff --git a/memind-integrations/claude-code/tests/test_config.py b/memind-integrations/claude-code/tests/test_config.py index ba24906a..e3543ff0 100644 --- a/memind-integrations/claude-code/tests/test_config.py +++ b/memind-integrations/claude-code/tests/test_config.py @@ -44,6 +44,7 @@ def test_defaults_match_spec(self): self.assertEqual(DEFAULT_SETTINGS["retrieveContextTurns"], 0) self.assertEqual(DEFAULT_SETTINGS["ingestionMode"], "extract-sync") self.assertEqual(DEFAULT_SETTINGS["sourceClient"], "claude-code") + self.assertTrue(DEFAULT_SETTINGS["autoIngestAgentTimeline"]) self.assertEqual(DEFAULT_SETTINGS["ingestionMaxMessagesPerHook"], 20) self.assertEqual(DEFAULT_SETTINGS["stateMaxAgeDays"], 14) @@ -54,6 +55,7 @@ def test_environment_overrides(self): env = { "MEMIND_API_URL": "http://memind.example", "MEMIND_AUTO_RETRIEVE": "false", + "MEMIND_AUTO_INGEST_AGENT_TIMELINE": "false", "MEMIND_INGESTION_ROLES": "user,assistant", "MEMIND_STATE_MAX_AGE_DAYS": "30", } @@ -61,6 +63,7 @@ def test_environment_overrides(self): config = load_config(plugin_root=plugin_root, user_config_path=plugin_root / "missing.json") self.assertEqual(config["memindApiUrl"], "http://memind.example") self.assertFalse(config["autoRetrieve"]) + self.assertFalse(config["autoIngestAgentTimeline"]) self.assertEqual(config["ingestionRoles"], ["user", "assistant"]) self.assertEqual(config["stateMaxAgeDays"], 30) self.assertEqual(config["retrieveMaxEntries"], 3) diff --git a/memind-integrations/claude-code/tests/test_hooks.py b/memind-integrations/claude-code/tests/test_hooks.py index 1f106ee5..270332d7 100644 --- a/memind-integrations/claude-code/tests/test_hooks.py +++ b/memind-integrations/claude-code/tests/test_hooks.py @@ -90,6 +90,57 @@ def test_session_end_without_transcript_fails_open(self): output = self.run_hook("session_end.py", {"cwd": tmp, "session_id": "s1"}, env=env) self.assertEqual(output, {"continue": True}) + def test_pre_tool_use_fails_open(self): + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + env = { + "CLAUDE_PLUGIN_ROOT": str(ROOT), + "PYTHONPATH": str(ROOT), + "MEMIND_CLAUDE_STATE_ROOT": str(state_dir), + } + output = self.run_hook( + "pre_tool_use.py", + { + "hook_event_name": "PreToolUse", + "cwd": tmp, + "session_id": "s1", + "tool_name": "Bash", + "tool_input": {"command": "npm test"}, + }, + env=env, + ) + self.assertEqual(output, {"continue": True}) + state_file = next(state_dir.glob("*.json")) + event = json.loads(state_file.read_text())["agentEvents"][0] + self.assertEqual(event["kind"], "command") + self.assertEqual(event["status"], "started") + + def test_post_tool_use_fails_open(self): + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + env = { + "CLAUDE_PLUGIN_ROOT": str(ROOT), + "PYTHONPATH": str(ROOT), + "MEMIND_CLAUDE_STATE_ROOT": str(state_dir), + } + output = self.run_hook( + "post_tool_use.py", + { + "hook_event_name": "PostToolUse", + "cwd": tmp, + "session_id": "s1", + "tool_name": "Bash", + "tool_input": {"command": "npm test"}, + "tool_response": {"exit_code": 0}, + }, + env=env, + ) + self.assertEqual(output, {"continue": True}) + state_file = next(state_dir.glob("*.json")) + event = json.loads(state_file.read_text())["agentEvents"][0] + self.assertEqual(event["kind"], "command") + self.assertEqual(event["status"], "success") + def test_retrieve_fail_open_when_memind_unavailable(self): with tempfile.TemporaryDirectory() as tmp: env = { @@ -221,6 +272,94 @@ def test_ingest_spools_full_extract_payload_on_partial_success(self): finally: transcript.unlink() + def test_ingest_flushes_agent_timeline_and_clears_events_on_success(self): + sys.path.insert(0, str(ROOT / "scripts")) + import ingest + from scripts.lib.state import SessionStateStore + + config = { + "memindApiUrl": "http://127.0.0.1:8366", + "memindApiToken": None, + "autoIngest": False, + "autoIngestAgentTimeline": True, + "ingestRetrySpool": True, + "sourceClient": "claude-code", + "agentId": "claude-code", + "agentIdMode": "global", + "userId": "u", + } + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + with SessionStateStore(state_dir).locked("s1") as state: + state.append_agent_event( + {"id": "e1", "seq": 1, "kind": "command", "command": "npm test"} + ) + with mock.patch.object(ingest, "state_root", return_value=state_dir): + with mock.patch.object(ingest, "retry_root", return_value=Path(tmp) / "retry"): + with mock.patch.object(ingest, "MemindClient") as client_cls: + client = client_cls.return_value + client.extract = mock.AsyncMock(return_value=types.SimpleNamespace(status="SUCCESS")) + result = ingest.ingest_messages( + config, + { + "session_id": "s1", + "cwd": tmp, + }, + ) + self.assertEqual(result["submitted"], 0) + client.extract.assert_awaited_once() + raw_content = client.extract.await_args.args[2] + self.assertEqual(raw_content["type"], "agent_timeline") + self.assertEqual(raw_content["sessionId"], "s1") + self.assertEqual(raw_content["events"][0]["id"], "e1") + with SessionStateStore(state_dir).locked("s1") as state: + self.assertEqual(state.agent_events(), []) + + def test_ingest_spools_agent_timeline_on_partial_success(self): + sys.path.insert(0, str(ROOT / "scripts")) + import ingest + from scripts.lib.state import SessionStateStore + + config = { + "memindApiUrl": "http://127.0.0.1:8366", + "memindApiToken": None, + "autoIngest": False, + "autoIngestAgentTimeline": True, + "ingestRetrySpool": True, + "sourceClient": "claude-code", + "agentId": "claude-code", + "agentIdMode": "global", + "userId": "u", + } + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + retry_dir = Path(tmp) / "retry" + with SessionStateStore(state_dir).locked("s1") as state: + state.append_agent_event( + {"id": "e1", "seq": 1, "kind": "command", "command": "npm test"} + ) + with mock.patch.object(ingest, "state_root", return_value=state_dir): + with mock.patch.object(ingest, "retry_root", return_value=retry_dir): + with mock.patch.object(ingest, "MemindClient") as client_cls: + client = client_cls.return_value + client.extract = mock.AsyncMock( + return_value=types.SimpleNamespace(status="PARTIAL_SUCCESS") + ) + ingest.ingest_messages( + config, + { + "session_id": "s1", + "cwd": tmp, + }, + ) + payload = json.loads(next(retry_dir.glob("*.json")).read_text()) + self.assertEqual(payload["kind"], "extract") + self.assertEqual(payload["eventIds"], ["e1"]) + self.assertEqual(payload["rawContent"]["type"], "agent_timeline") + self.assertEqual(payload["rawContent"]["events"][0]["id"], "e1") + with SessionStateStore(state_dir).locked("s1") as state: + self.assertEqual(len(state.agent_events()), 1) + def test_session_start_replays_extract_payload_and_marks_fingerprints(self): sys.path.insert(0, str(ROOT / "scripts")) import session_start @@ -319,6 +458,58 @@ def test_session_start_keeps_extract_payload_when_replay_is_not_success(self): self.assertEqual(len(list(retry_dir.glob("*.json"))), 1) client.extract.assert_awaited_once() + def test_session_start_replays_agent_timeline_payload_and_clears_event_ids(self): + sys.path.insert(0, str(ROOT / "scripts")) + import session_start + from scripts.lib.retry import RetrySpool + from scripts.lib.state import SessionStateStore + + with tempfile.TemporaryDirectory() as tmp: + retry_dir = Path(tmp) / "retry" + state_dir = Path(tmp) / "state" + with SessionStateStore(state_dir).locked("s1") as state: + state.append_agent_event({"id": "e1", "seq": 1, "kind": "command"}) + state.append_agent_event({"id": "e2", "seq": 2, "kind": "tool"}) + RetrySpool(retry_dir).enqueue( + { + "kind": "extract", + "userId": "u", + "agentId": "a", + "sourceClient": "claude-code", + "sessionId": "s1", + "eventIds": ["e1"], + "rawContent": { + "type": "agent_timeline", + "sourceClient": "claude-code", + "sessionId": "s1", + "timelineId": "s1-agent", + "events": [{"id": "e1", "seq": 1, "kind": "command"}], + }, + } + ) + config = { + "memindApiUrl": "http://127.0.0.1:8366", + "memindApiToken": None, + "ingestRetryMaxFiles": 20, + "ingestRetryMaxAgeDays": 7, + "stateMaxAgeDays": 14, + "debug": False, + } + with mock.patch.object(session_start, "load_config", return_value=config): + with mock.patch.object(session_start, "retry_root", return_value=retry_dir): + with mock.patch.object(session_start, "state_root", return_value=state_dir): + with mock.patch.object(session_start, "MemindClient") as client_cls: + client = client_cls.return_value + client.health = mock.AsyncMock(return_value=types.SimpleNamespace(status="UP")) + client.extract = mock.AsyncMock(return_value=types.SimpleNamespace(status="SUCCESS")) + client.add_message = mock.AsyncMock(return_value=None) + client.commit = mock.AsyncMock(return_value=None) + session_start.main() + with SessionStateStore(state_dir).locked("s1") as state: + self.assertEqual(state.agent_events(), [{"id": "e2", "seq": 2, "kind": "tool"}]) + self.assertEqual(list(retry_dir.glob("*.json")), []) + client.extract.assert_awaited_once() + def test_session_start_recovers_orphaned_claims_before_replay(self): sys.path.insert(0, str(ROOT / "scripts")) import session_start diff --git a/memind-integrations/claude-code/tests/test_manifest.py b/memind-integrations/claude-code/tests/test_manifest.py index 1351a641..a3517931 100644 --- a/memind-integrations/claude-code/tests/test_manifest.py +++ b/memind-integrations/claude-code/tests/test_manifest.py @@ -27,7 +27,15 @@ def test_plugin_json_has_no_hooks_field(self): def test_hooks_json_shape(self): hooks = json.loads((ROOT / "hooks" / "hooks.json").read_text())["hooks"] - for event in ["SessionStart", "UserPromptSubmit", "PreCompact", "Stop", "SessionEnd"]: + for event in [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PreCompact", + "Stop", + "SessionEnd", + ]: self.assertIn(event, hooks) event_hooks = hooks[event] self.assertIsInstance(event_hooks, list) @@ -35,11 +43,14 @@ def test_hooks_json_shape(self): self.assertIn("hooks", event_hooks[0]) stop_command = hooks["Stop"][0]["hooks"][0] self.assertTrue(stop_command["async"]) + self.assertTrue(hooks["PreToolUse"][0]["hooks"][0]["async"]) + self.assertTrue(hooks["PostToolUse"][0]["hooks"][0]["async"]) def test_default_settings(self): settings = json.loads((ROOT / "settings.json").read_text()) self.assertEqual(settings["retrieveContextTurns"], 0) self.assertEqual(settings["ingestionMode"], "extract-sync") + self.assertTrue(settings["autoIngestAgentTimeline"]) self.assertEqual(settings["ingestionMaxMessagesPerHook"], 20) self.assertEqual(settings["stateMaxAgeDays"], 14) diff --git a/memind-integrations/claude-code/tests/test_state.py b/memind-integrations/claude-code/tests/test_state.py index 5901d2b8..18a49b3b 100644 --- a/memind-integrations/claude-code/tests/test_state.py +++ b/memind-integrations/claude-code/tests/test_state.py @@ -44,6 +44,31 @@ def test_cleanup_removes_old_state(self): self.assertEqual(removed, 1) self.assertFalse(old_file.exists()) + def test_agent_events_are_deduplicated_and_clear_by_id(self): + with tempfile.TemporaryDirectory() as tmp: + store = SessionStateStore(Path(tmp)) + with store.locked("session-1") as state: + self.assertEqual(state.next_agent_seq(), 1) + self.assertEqual(state.next_agent_seq(), 2) + state.append_agent_event({"id": "e1", "seq": 1}) + state.append_agent_event({"id": "e1", "seq": 1}) + state.append_agent_event({"id": "e2", "seq": 2}) + state.clear_agent_events(["e1"]) + with store.locked("session-1") as state: + self.assertEqual(state.agent_events(), [{"id": "e2", "seq": 2}]) + + def test_agent_event_buffer_has_soft_cap(self): + with tempfile.TemporaryDirectory() as tmp: + store = SessionStateStore(Path(tmp)) + with store.locked("session-1") as state: + for index in range(501): + state.append_agent_event({"id": f"e{index}", "seq": index}) + with store.locked("session-1") as state: + events = state.agent_events() + self.assertEqual(len(events), 500) + self.assertEqual(events[0]["id"], "e1") + self.assertTrue(state.data["agentEventsTruncated"]) + if __name__ == "__main__": unittest.main() From 1c92b37a635b6252b46b22e58e0293f51228c3b6 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 25 May 2026 14:50:00 +0800 Subject: [PATCH 16/54] feat(codex): capture agent timelines --- .../claude-code/scripts/lib/agent_timeline.py | 7 +- .../claude-code/tests/test_agent_timeline.py | 3 +- .../claude-code/tests/test_hooks.py | 6 +- memind-integrations/codex/hooks/hooks.json | 22 +++ memind-integrations/codex/install.sh | 3 + memind-integrations/codex/scripts/ingest.py | 64 +++++++ .../codex/scripts/lib/agent_timeline.py | 170 +++++++++++++++++ .../codex/scripts/lib/config.py | 2 + .../codex/scripts/lib/state.py | 37 ++++ .../codex/scripts/post_tool_use.py | 48 +++++ .../codex/scripts/pre_tool_use.py | 48 +++++ .../codex/scripts/session_start.py | 3 + memind-integrations/codex/settings.json | 1 + .../codex/tests/test_agent_timeline.py | 92 +++++++++ memind-integrations/codex/tests/test_hooks.py | 179 ++++++++++++++++++ .../codex/tests/test_manifest.py | 7 +- memind-integrations/codex/tests/test_state.py | 25 +++ 17 files changed, 708 insertions(+), 9 deletions(-) create mode 100644 memind-integrations/codex/scripts/lib/agent_timeline.py create mode 100644 memind-integrations/codex/scripts/post_tool_use.py create mode 100644 memind-integrations/codex/scripts/pre_tool_use.py create mode 100644 memind-integrations/codex/tests/test_agent_timeline.py diff --git a/memind-integrations/claude-code/scripts/lib/agent_timeline.py b/memind-integrations/claude-code/scripts/lib/agent_timeline.py index 376769c5..5bdb868f 100644 --- a/memind-integrations/claude-code/scripts/lib/agent_timeline.py +++ b/memind-integrations/claude-code/scripts/lib/agent_timeline.py @@ -118,7 +118,7 @@ def normalize_hook_event(hook_input, seq): event["exitCode"] = exit_code event["status"] = "success" if exit_code == 0 else "failed" else: - event["status"] = "success" if hook_input.get("hook_event_name") == "PostToolUse" else "started" + event["status"] = "success" if hook_input.get("hook_event_name") == "PostToolUse" else "running" output = { key: value @@ -143,7 +143,7 @@ def normalize_hook_event(hook_input, seq): def _event_kind(tool_name): if tool_name == "Bash": return "command" - return "tool" + return "tool_result" def append_event(state, event): @@ -167,5 +167,6 @@ def build_timeline_payload(config, identity, session_id, events, hook_input): }, } if cwd: - payload["project"] = {"cwd": str(Path(cwd))} + path = Path(cwd) + payload["project"] = {"name": path.name, "rootPath": str(path)} return payload diff --git a/memind-integrations/claude-code/tests/test_agent_timeline.py b/memind-integrations/claude-code/tests/test_agent_timeline.py index 540c2010..5d8d93f1 100644 --- a/memind-integrations/claude-code/tests/test_agent_timeline.py +++ b/memind-integrations/claude-code/tests/test_agent_timeline.py @@ -87,7 +87,8 @@ def test_builds_agent_timeline_payload(self): self.assertEqual(payload["sessionId"], "s") self.assertEqual(payload["timelineId"], "s-agent-1-1") self.assertEqual(payload["events"][0]["seq"], 1) - self.assertEqual(payload["project"]["cwd"], "/tmp/project") + self.assertEqual(payload["project"]["name"], "project") + self.assertEqual(payload["project"]["rootPath"], "/tmp/project") if __name__ == "__main__": diff --git a/memind-integrations/claude-code/tests/test_hooks.py b/memind-integrations/claude-code/tests/test_hooks.py index 270332d7..4b02c907 100644 --- a/memind-integrations/claude-code/tests/test_hooks.py +++ b/memind-integrations/claude-code/tests/test_hooks.py @@ -113,7 +113,7 @@ def test_pre_tool_use_fails_open(self): state_file = next(state_dir.glob("*.json")) event = json.loads(state_file.read_text())["agentEvents"][0] self.assertEqual(event["kind"], "command") - self.assertEqual(event["status"], "started") + self.assertEqual(event["status"], "running") def test_post_tool_use_fails_open(self): with tempfile.TemporaryDirectory() as tmp: @@ -469,7 +469,7 @@ def test_session_start_replays_agent_timeline_payload_and_clears_event_ids(self) state_dir = Path(tmp) / "state" with SessionStateStore(state_dir).locked("s1") as state: state.append_agent_event({"id": "e1", "seq": 1, "kind": "command"}) - state.append_agent_event({"id": "e2", "seq": 2, "kind": "tool"}) + state.append_agent_event({"id": "e2", "seq": 2, "kind": "tool_result"}) RetrySpool(retry_dir).enqueue( { "kind": "extract", @@ -506,7 +506,7 @@ def test_session_start_replays_agent_timeline_payload_and_clears_event_ids(self) client.commit = mock.AsyncMock(return_value=None) session_start.main() with SessionStateStore(state_dir).locked("s1") as state: - self.assertEqual(state.agent_events(), [{"id": "e2", "seq": 2, "kind": "tool"}]) + self.assertEqual(state.agent_events(), [{"id": "e2", "seq": 2, "kind": "tool_result"}]) self.assertEqual(list(retry_dir.glob("*.json")), []) client.extract.assert_awaited_once() diff --git a/memind-integrations/codex/hooks/hooks.json b/memind-integrations/codex/hooks/hooks.json index 8e548790..d68618b3 100644 --- a/memind-integrations/codex/hooks/hooks.json +++ b/memind-integrations/codex/hooks/hooks.json @@ -22,6 +22,28 @@ ] } ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CODEX_PLUGIN_ROOT}/scripts/pre_tool_use.py\"", + "timeout": 5 + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CODEX_PLUGIN_ROOT}/scripts/post_tool_use.py\"", + "timeout": 5 + } + ] + } + ], "Stop": [ { "hooks": [ diff --git a/memind-integrations/codex/install.sh b/memind-integrations/codex/install.sh index 882c3f15..8e91ba38 100644 --- a/memind-integrations/codex/install.sh +++ b/memind-integrations/codex/install.sh @@ -177,9 +177,12 @@ download_remote_install() { "hooks/hooks.json" "scripts/install_codex_hooks.py" "scripts/ingest.py" + "scripts/pre_tool_use.py" + "scripts/post_tool_use.py" "scripts/retrieve.py" "scripts/session_start.py" "scripts/lib/__init__.py" + "scripts/lib/agent_timeline.py" "scripts/lib/client.py" "scripts/lib/config.py" "scripts/lib/content.py" diff --git a/memind-integrations/codex/scripts/ingest.py b/memind-integrations/codex/scripts/ingest.py index ca843bc0..984cc4b5 100644 --- a/memind-integrations/codex/scripts/ingest.py +++ b/memind-integrations/codex/scripts/ingest.py @@ -23,6 +23,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from lib.client import MemindClient +from lib.agent_timeline import build_timeline_payload from lib.config import load_config from lib.content import extract_messages from lib.identity import resolve_identity @@ -32,10 +33,16 @@ def state_root(): + override = os.environ.get("MEMIND_CODEX_STATE_ROOT") + if override: + return Path(override) return Path.home() / ".memind" / "codex" / "state" def retry_root(): + override = os.environ.get("MEMIND_CODEX_RETRY_ROOT") + if override: + return Path(override) return Path.home() / ".memind" / "codex" / "retry" @@ -66,6 +73,22 @@ def _spool_extract(retry_spool, identity, source_client, session_key, messages): ) +def _spool_agent_timeline(retry_spool, identity, source_client, session_key, events, raw_content): + if retry_spool is None or not events: + return + retry_spool.enqueue( + { + "kind": "extract", + "userId": identity["userId"], + "agentId": identity["agentId"], + "sourceClient": source_client, + "sessionKey": session_key, + "eventIds": [event["id"] for event in events if event.get("id")], + "rawContent": raw_content, + } + ) + + async def ingest_messages_async(config, hook_input): identity = resolve_identity(config, hook_input) client = MemindClient(config["memindApiUrl"], config.get("memindApiToken"), timeout=10, max_retries=0) @@ -82,6 +105,7 @@ async def ingest_messages_async(config, hook_input): with store.locked(session_key) as state: selected = [message for message in messages if not state.is_submitted(message["fingerprint"])][:limit] + agent_events = state.agent_events() if config.get("autoIngestAgentTimeline", True) else [] submitted = [] if selected: @@ -102,6 +126,46 @@ async def ingest_messages_async(config, hook_input): else: _spool_extract(retry_spool, identity, source_client, session_key, selected) + if agent_events: + timeline_payload = build_timeline_payload( + config, + identity, + session_key, + agent_events, + hook_input, + ) + try: + response = await client.extract( + identity["userId"], + identity["agentId"], + timeline_payload, + source_client, + ) + except Exception: + _spool_agent_timeline( + retry_spool, + identity, + source_client, + session_key, + agent_events, + timeline_payload, + ) + else: + status = getattr(response, "status", None) + if status == "SUCCESS": + store.clear_agent_events( + session_key, [event["id"] for event in agent_events if event.get("id")] + ) + else: + _spool_agent_timeline( + retry_spool, + identity, + source_client, + session_key, + agent_events, + timeline_payload, + ) + return {"submitted": len(submitted), "committed": False} diff --git a/memind-integrations/codex/scripts/lib/agent_timeline.py b/memind-integrations/codex/scripts/lib/agent_timeline.py new file mode 100644 index 00000000..7df0d496 --- /dev/null +++ b/memind-integrations/codex/scripts/lib/agent_timeline.py @@ -0,0 +1,170 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import hashlib +import json +import re +from pathlib import Path + + +MAX_TEXT_CHARS = 4000 + +SECRET_PATTERNS = [ + ("openai_key", re.compile(r"sk-[A-Za-z0-9_-]{8,}")), + ("bearer_token", re.compile(r"Bearer\s+[A-Za-z0-9._~+/=-]+", re.IGNORECASE)), + ("private_key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.DOTALL)), +] + + +def redact_text(text): + redacted = str(text) + kinds = [] + for kind, pattern in SECRET_PATTERNS: + if pattern.search(redacted): + redacted = pattern.sub(f"[REDACTED:{kind}]", redacted) + kinds.append(kind) + if len(redacted) > MAX_TEXT_CHARS: + redacted = redacted[:MAX_TEXT_CHARS] + kinds.append("truncated") + return redacted, sorted(set(kinds)) + + +def _redact_value(value): + if value is None or isinstance(value, (bool, int, float)): + return value, [] + if isinstance(value, str): + return redact_text(value) + if isinstance(value, list): + result = [] + kinds = [] + for item in value: + redacted, item_kinds = _redact_value(item) + result.append(redacted) + kinds.extend(item_kinds) + return result, sorted(set(kinds)) + if isinstance(value, dict): + result = {} + kinds = [] + for key, item in value.items(): + redacted, item_kinds = _redact_value(item) + result[key] = redacted + kinds.extend(item_kinds) + return result, sorted(set(kinds)) + redacted, kinds = redact_text(value) + return redacted, kinds + + +def _json_text(value): + if value is None or isinstance(value, str): + return value + return json.dumps(value, ensure_ascii=False, sort_keys=True) + + +def event_id(source_client, session_id, seq, hook_input): + hook_name = hook_input.get("hook_event_name") or "" + tool_name = hook_input.get("tool_name") or "" + timestamp = hook_input.get("timestamp") or "" + stable = json.dumps( + { + "sourceClient": source_client, + "sessionId": session_id, + "seq": seq, + "hook": hook_name, + "tool": tool_name, + "timestamp": timestamp, + }, + sort_keys=True, + ) + return hashlib.sha256(stable.encode("utf-8")).hexdigest() + + +def normalize_hook_event(hook_input, seq): + source_client = hook_input.get("source_client") or "codex" + session_id = hook_input.get("session_id") or "unknown-session" + tool_name = hook_input.get("tool_name") + tool_input = hook_input.get("tool_input") or {} + tool_response = hook_input.get("tool_response") or {} + exit_code = tool_response.get("exit_code") + redaction_kinds = [] + + event = { + "id": event_id(source_client, session_id, seq, hook_input), + "seq": seq, + "kind": _event_kind(tool_name), + "occurredAt": hook_input.get("timestamp"), + "toolName": tool_name, + } + if tool_name == "Bash" and isinstance(tool_input, dict): + command, kinds = redact_text(tool_input.get("command") or "") + event["command"] = command + redaction_kinds.extend(kinds) + else: + redacted_input, kinds = _redact_value(tool_input) + event["input"] = _json_text(redacted_input) + redaction_kinds.extend(kinds) + + if exit_code is not None: + event["exitCode"] = exit_code + event["status"] = "success" if exit_code == 0 else "failed" + else: + event["status"] = "success" if hook_input.get("hook_event_name") == "PostToolUse" else "running" + + output = { + key: value + for key, value in tool_response.items() + if key not in {"exit_code", "exitCode"} and value is not None + } + if output: + redacted_output, kinds = _redact_value(output) + event["output"] = _json_text(redacted_output) + redaction_kinds.extend(kinds) + + metadata = {"hookEventName": hook_input.get("hook_event_name")} + if redaction_kinds: + metadata["redacted"] = True + metadata["redactionKinds"] = sorted(set(redaction_kinds)) + event["metadata"] = {key: value for key, value in metadata.items() if value is not None} + return {key: value for key, value in event.items() if value is not None} + + +def _event_kind(tool_name): + if tool_name == "Bash": + return "command" + return "tool_result" + + +def append_event(state, event): + state.append_agent_event(event) + + +def build_timeline_payload(config, identity, session_id, events, hook_input): + source_client = config.get("sourceClient") or "codex" + cwd = hook_input.get("cwd") + first_seq = events[0].get("seq") if events else 0 + last_seq = events[-1].get("seq") if events else 0 + payload = { + "type": "agent_timeline", + "sourceClient": source_client, + "sessionId": session_id, + "timelineId": f"{session_id}-agent-{first_seq}-{last_seq}", + "events": list(events), + "metadata": { + "userId": identity.get("userId"), + "agentId": identity.get("agentId"), + }, + } + if cwd: + path = Path(cwd) + payload["project"] = {"name": path.name, "rootPath": str(path)} + return payload diff --git a/memind-integrations/codex/scripts/lib/config.py b/memind-integrations/codex/scripts/lib/config.py index 47d47eec..0710b3c4 100644 --- a/memind-integrations/codex/scripts/lib/config.py +++ b/memind-integrations/codex/scripts/lib/config.py @@ -25,6 +25,7 @@ "sourceClient": "codex", "autoRetrieve": True, "autoIngest": True, + "autoIngestAgentTimeline": True, "commitOnStop": False, "retrieveStrategy": "SIMPLE", "retrieveMaxEntries": 8, @@ -50,6 +51,7 @@ "MEMIND_SOURCE_CLIENT": ("sourceClient", str), "MEMIND_AUTO_RETRIEVE": ("autoRetrieve", "bool"), "MEMIND_AUTO_INGEST": ("autoIngest", "bool"), + "MEMIND_AUTO_INGEST_AGENT_TIMELINE": ("autoIngestAgentTimeline", "bool"), "MEMIND_COMMIT_ON_STOP": ("commitOnStop", "bool"), "MEMIND_RETRIEVE_STRATEGY": ("retrieveStrategy", str), "MEMIND_RETRIEVE_MAX_ENTRIES": ("retrieveMaxEntries", "int"), diff --git a/memind-integrations/codex/scripts/lib/state.py b/memind-integrations/codex/scripts/lib/state.py index 92ca70bb..c9f25ff5 100644 --- a/memind-integrations/codex/scripts/lib/state.py +++ b/memind-integrations/codex/scripts/lib/state.py @@ -21,6 +21,7 @@ from pathlib import Path SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9_.-]+") +MAX_AGENT_EVENTS = 500 def _hash(value): @@ -89,6 +90,8 @@ class SessionState: def __init__(self, data): self.data = data self.data.setdefault("submitted", []) + self.data.setdefault("agentEvents", []) + self.data.setdefault("nextAgentSeq", 1) def is_submitted(self, fingerprint): return fingerprint in set(self.data.get("submitted", [])) @@ -99,6 +102,36 @@ def mark_submitted(self, fingerprints): self.data["submitted"] = sorted(submitted) self.data["updatedAt"] = time.time() + def append_agent_event(self, event): + events = list(self.data.get("agentEvents", [])) + event_id = event.get("id") + if event_id and any(existing.get("id") == event_id for existing in events): + return + events.append(event) + if len(events) > MAX_AGENT_EVENTS: + events = events[-MAX_AGENT_EVENTS:] + self.data["agentEventsTruncated"] = True + self.data["agentEvents"] = events + self.data["updatedAt"] = time.time() + + def agent_events(self): + return list(self.data.get("agentEvents", [])) + + def clear_agent_events(self, event_ids): + event_ids = set(event_ids or []) + if not event_ids: + return + self.data["agentEvents"] = [ + event for event in self.data.get("agentEvents", []) if event.get("id") not in event_ids + ] + self.data["updatedAt"] = time.time() + + def next_agent_seq(self): + seq = int(self.data.get("nextAgentSeq", 1)) + self.data["nextAgentSeq"] = seq + 1 + self.data["updatedAt"] = time.time() + return seq + class SessionStateStore: def __init__(self, root): @@ -136,6 +169,10 @@ def mark_submitted(self, session_key, fingerprints): with self.locked(session_key) as state: state.mark_submitted(fingerprints) + def clear_agent_events(self, session_key, event_ids): + with self.locked(session_key) as state: + state.clear_agent_events(event_ids) + def cleanup(self, max_age_days): cutoff = time.time() - max_age_days * 86400 removed = 0 diff --git a/memind-integrations/codex/scripts/post_tool_use.py b/memind-integrations/codex/scripts/post_tool_use.py new file mode 100644 index 00000000..42961872 --- /dev/null +++ b/memind-integrations/codex/scripts/post_tool_use.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from ingest import state_root +from lib.agent_timeline import normalize_hook_event +from lib.config import load_config +from lib.logging_utils import debug_log +from lib.state import SessionStateStore, state_key + + +def main(): + try: + hook_input = json.loads(sys.stdin.read() or "{}") + config = load_config() + hook_input["source_client"] = config.get("sourceClient") or "codex" + session_key = state_key(hook_input) + with SessionStateStore(state_root()).locked(session_key) as state: + seq = state.next_agent_seq() + event = normalize_hook_event(hook_input, seq) + state.append_agent_event(event) + except Exception as exc: + try: + debug_log(load_config(), "post_tool_use_failed", {"error": str(exc)}) + except Exception: + pass + print(json.dumps({"continue": True})) + + +if __name__ == "__main__": + main() diff --git a/memind-integrations/codex/scripts/pre_tool_use.py b/memind-integrations/codex/scripts/pre_tool_use.py new file mode 100644 index 00000000..9a7ef987 --- /dev/null +++ b/memind-integrations/codex/scripts/pre_tool_use.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from ingest import state_root +from lib.agent_timeline import normalize_hook_event +from lib.config import load_config +from lib.logging_utils import debug_log +from lib.state import SessionStateStore, state_key + + +def main(): + try: + hook_input = json.loads(sys.stdin.read() or "{}") + config = load_config() + hook_input["source_client"] = config.get("sourceClient") or "codex" + session_key = state_key(hook_input) + with SessionStateStore(state_root()).locked(session_key) as state: + seq = state.next_agent_seq() + event = normalize_hook_event(hook_input, seq) + state.append_agent_event(event) + except Exception as exc: + try: + debug_log(load_config(), "pre_tool_use_failed", {"error": str(exc)}) + except Exception: + pass + print(json.dumps({"continue": True})) + + +if __name__ == "__main__": + main() diff --git a/memind-integrations/codex/scripts/session_start.py b/memind-integrations/codex/scripts/session_start.py index b3e9d833..fa6fcaa3 100644 --- a/memind-integrations/codex/scripts/session_start.py +++ b/memind-integrations/codex/scripts/session_start.py @@ -83,6 +83,9 @@ async def _replay_payload(client, payload): fingerprints = payload.get("fingerprints") or [] if session_key and fingerprints: SessionStateStore(state_root()).mark_submitted(session_key, fingerprints) + event_ids = payload.get("eventIds") or [] + if session_key and event_ids: + SessionStateStore(state_root()).clear_agent_events(session_key, event_ids) return len(fingerprints) if kind == "ingestion-batch": return await _replay_ingestion_batch(client, payload) diff --git a/memind-integrations/codex/settings.json b/memind-integrations/codex/settings.json index 22b59be0..1f1a4599 100644 --- a/memind-integrations/codex/settings.json +++ b/memind-integrations/codex/settings.json @@ -7,6 +7,7 @@ "sourceClient": "codex", "autoRetrieve": true, "autoIngest": true, + "autoIngestAgentTimeline": true, "commitOnStop": false, "retrieveStrategy": "SIMPLE", "retrieveMaxEntries": 8, diff --git a/memind-integrations/codex/tests/test_agent_timeline.py b/memind-integrations/codex/tests/test_agent_timeline.py new file mode 100644 index 00000000..d86c8e50 --- /dev/null +++ b/memind-integrations/codex/tests/test_agent_timeline.py @@ -0,0 +1,92 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import json +import unittest + +from scripts.lib.agent_timeline import build_timeline_payload, normalize_hook_event + + +class AgentTimelineTest(unittest.TestCase): + def test_normalizes_post_tool_use_to_command_event(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "Bash", + "tool_input": {"command": "cargo test payment"}, + "tool_response": {"exit_code": 1, "stderr": "rounding mismatch"}, + "timestamp": "2026-05-24T10:00:00Z", + "source_client": "codex", + }, + seq=1, + ) + + self.assertEqual(event["kind"], "command") + self.assertEqual(event["seq"], 1) + self.assertEqual(event["command"], "cargo test payment") + self.assertEqual(event["status"], "failed") + self.assertEqual(event["exitCode"], 1) + self.assertEqual(event["output"], '{"stderr": "rounding mismatch"}') + + def test_redacts_secret_fields_before_spool(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "Bash", + "tool_input": {"command": "echo sk-test-secret"}, + "tool_response": {"exit_code": 0, "stdout": "Bearer abc.def.ghi"}, + "source_client": "codex", + }, + seq=1, + ) + + serialized = json.dumps(event) + self.assertNotIn("sk-test-secret", serialized) + self.assertNotIn("abc.def.ghi", serialized) + self.assertIn("[REDACTED", serialized) + + def test_builds_agent_timeline_payload(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "Bash", + "tool_input": {"command": "cargo test"}, + "tool_response": {"exit_code": 0}, + "source_client": "codex", + }, + seq=1, + ) + + payload = build_timeline_payload( + config={"sourceClient": "codex"}, + identity={"userId": "u", "agentId": "a"}, + session_id="s", + events=[event], + hook_input={"cwd": "/tmp/project"}, + ) + + self.assertEqual(payload["type"], "agent_timeline") + self.assertEqual(payload["sourceClient"], "codex") + self.assertEqual(payload["sessionId"], "s") + self.assertEqual(payload["timelineId"], "s-agent-1-1") + self.assertEqual(payload["events"][0]["seq"], 1) + self.assertEqual(payload["project"]["name"], "project") + self.assertEqual(payload["project"]["rootPath"], "/tmp/project") + + +if __name__ == "__main__": + unittest.main() diff --git a/memind-integrations/codex/tests/test_hooks.py b/memind-integrations/codex/tests/test_hooks.py index 18e6b9a3..33382654 100644 --- a/memind-integrations/codex/tests/test_hooks.py +++ b/memind-integrations/codex/tests/test_hooks.py @@ -88,6 +88,57 @@ def test_ingest_without_transcript_fails_open(self): output = self.run_hook("ingest.py", {"cwd": tmp, "session_id": "s1"}, env=env) self.assertEqual(output, {"continue": True}) + def test_pre_tool_use_fails_open_and_buffers_event(self): + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + env = { + "CODEX_PLUGIN_ROOT": str(ROOT), + "PYTHONPATH": str(ROOT), + "MEMIND_CODEX_STATE_ROOT": str(state_dir), + } + output = self.run_hook( + "pre_tool_use.py", + { + "hook_event_name": "PreToolUse", + "cwd": tmp, + "session_id": "s1", + "tool_name": "Bash", + "tool_input": {"command": "cargo test"}, + }, + env=env, + ) + self.assertEqual(output, {"continue": True}) + state_file = next(state_dir.glob("*.json")) + event = json.loads(state_file.read_text())["agentEvents"][0] + self.assertEqual(event["kind"], "command") + self.assertEqual(event["status"], "running") + + def test_post_tool_use_fails_open_and_buffers_event(self): + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + env = { + "CODEX_PLUGIN_ROOT": str(ROOT), + "PYTHONPATH": str(ROOT), + "MEMIND_CODEX_STATE_ROOT": str(state_dir), + } + output = self.run_hook( + "post_tool_use.py", + { + "hook_event_name": "PostToolUse", + "cwd": tmp, + "session_id": "s1", + "tool_name": "Bash", + "tool_input": {"command": "cargo test"}, + "tool_response": {"exit_code": 0}, + }, + env=env, + ) + self.assertEqual(output, {"continue": True}) + state_file = next(state_dir.glob("*.json")) + event = json.loads(state_file.read_text())["agentEvents"][0] + self.assertEqual(event["kind"], "command") + self.assertEqual(event["status"], "success") + def test_ingest_uses_extract_sync_and_ignores_commit_flag_in_reliable_mode(self): sys.path.insert(0, str(ROOT / "scripts")) import ingest @@ -258,6 +309,83 @@ def test_ingest_spools_full_extract_payload_on_partial_success(self): finally: transcript.unlink() + def test_ingest_flushes_agent_timeline_and_clears_events_on_success(self): + sys.path.insert(0, str(ROOT / "scripts")) + import ingest + from scripts.lib.state import SessionStateStore + + config = { + "memindApiUrl": "http://127.0.0.1:8366", + "memindApiToken": None, + "autoIngest": False, + "autoIngestAgentTimeline": True, + "ingestRetrySpool": True, + "sourceClient": "codex", + "agentId": "codex", + "agentIdMode": "global", + "userId": "u", + } + with tempfile.TemporaryDirectory() as tmp: + state_root = Path(tmp) / "state" + with SessionStateStore(state_root).locked("s1") as state: + state.append_agent_event( + {"id": "e1", "seq": 1, "kind": "command", "command": "cargo test"} + ) + with mock.patch.object(ingest, "state_root", return_value=state_root): + with mock.patch.object(ingest, "retry_root", return_value=Path(tmp) / "retry"): + with mock.patch.object(ingest, "MemindClient") as client_cls: + client = client_cls.return_value + client.extract = mock.AsyncMock(return_value=types.SimpleNamespace(status="SUCCESS")) + result = ingest.ingest_messages(config, {"session_id": "s1", "cwd": tmp}) + self.assertEqual(result["submitted"], 0) + client.extract.assert_awaited_once() + raw_content = client.extract.await_args.args[2] + self.assertEqual(raw_content["type"], "agent_timeline") + self.assertEqual(raw_content["sessionId"], "s1") + self.assertEqual(raw_content["events"][0]["id"], "e1") + with SessionStateStore(state_root).locked("s1") as state: + self.assertEqual(state.agent_events(), []) + + def test_ingest_spools_agent_timeline_on_partial_success(self): + sys.path.insert(0, str(ROOT / "scripts")) + import ingest + from scripts.lib.state import SessionStateStore + + config = { + "memindApiUrl": "http://127.0.0.1:8366", + "memindApiToken": None, + "autoIngest": False, + "autoIngestAgentTimeline": True, + "ingestRetrySpool": True, + "sourceClient": "codex", + "agentId": "codex", + "agentIdMode": "global", + "userId": "u", + } + with tempfile.TemporaryDirectory() as tmp: + state_root = Path(tmp) / "state" + retry_root = Path(tmp) / "retry" + with SessionStateStore(state_root).locked("s1") as state: + state.append_agent_event( + {"id": "e1", "seq": 1, "kind": "command", "command": "cargo test"} + ) + with mock.patch.object(ingest, "state_root", return_value=state_root): + with mock.patch.object(ingest, "retry_root", return_value=retry_root): + with mock.patch.object(ingest, "MemindClient") as client_cls: + client = client_cls.return_value + client.extract = mock.AsyncMock( + return_value=types.SimpleNamespace(status="PARTIAL_SUCCESS") + ) + ingest.ingest_messages(config, {"session_id": "s1", "cwd": tmp}) + payload = json.loads(next(retry_root.glob("*.json")).read_text()) + self.assertEqual(payload["kind"], "extract") + self.assertEqual(payload["sessionKey"], "s1") + self.assertEqual(payload["eventIds"], ["e1"]) + self.assertEqual(payload["rawContent"]["type"], "agent_timeline") + self.assertEqual(payload["rawContent"]["events"][0]["id"], "e1") + with SessionStateStore(state_root).locked("s1") as state: + self.assertEqual(len(state.agent_events()), 1) + def test_session_start_fail_open_when_memind_unavailable(self): with tempfile.TemporaryDirectory() as tmp: env = { @@ -418,6 +546,57 @@ def test_session_start_keeps_extract_payload_when_replay_is_not_success(self): self.assertEqual(len(list(retry_root.glob("*.json"))), 1) client.extract.assert_awaited_once() + def test_session_start_replays_agent_timeline_payload_and_clears_event_ids(self): + sys.path.insert(0, str(ROOT / "scripts")) + import session_start + from scripts.lib.retry import RetrySpool + from scripts.lib.state import SessionStateStore + + with tempfile.TemporaryDirectory() as tmp: + retry_root = Path(tmp) / "retry" + state_root = Path(tmp) / "state" + with SessionStateStore(state_root).locked("s1") as state: + state.append_agent_event({"id": "e1", "seq": 1, "kind": "command"}) + state.append_agent_event({"id": "e2", "seq": 2, "kind": "tool_result"}) + RetrySpool(retry_root).enqueue( + { + "kind": "extract", + "userId": "u", + "agentId": "a", + "sourceClient": "codex", + "sessionKey": "s1", + "eventIds": ["e1"], + "rawContent": { + "type": "agent_timeline", + "sourceClient": "codex", + "sessionId": "s1", + "timelineId": "s1-agent-1-1", + "events": [{"id": "e1", "seq": 1, "kind": "command"}], + }, + } + ) + config = { + "memindApiUrl": "http://127.0.0.1:8366", + "memindApiToken": None, + "ingestRetryMaxFiles": 20, + "ingestRetryMaxAgeDays": 7, + "stateMaxAgeDays": 14, + "debug": False, + } + with mock.patch.object(session_start, "retry_root", return_value=retry_root): + with mock.patch.object(session_start, "state_root", return_value=state_root): + with mock.patch.object(session_start, "MemindClient") as client_cls: + client = client_cls.return_value + client.health = mock.AsyncMock(return_value=types.SimpleNamespace(status="UP")) + client.extract = mock.AsyncMock(return_value=types.SimpleNamespace(status="SUCCESS")) + client.add_message = mock.AsyncMock(return_value=None) + client.commit = mock.AsyncMock(return_value=None) + session_start.run_session_start(config) + with SessionStateStore(state_root).locked("s1") as state: + self.assertEqual(state.agent_events(), [{"id": "e2", "seq": 2, "kind": "tool_result"}]) + self.assertEqual(list(retry_root.glob("*.json")), []) + client.extract.assert_awaited_once() + def test_session_start_skips_replayed_message_already_submitted_by_later_stop(self): sys.path.insert(0, str(ROOT / "scripts")) import session_start diff --git a/memind-integrations/codex/tests/test_manifest.py b/memind-integrations/codex/tests/test_manifest.py index 46adb20c..5059018c 100644 --- a/memind-integrations/codex/tests/test_manifest.py +++ b/memind-integrations/codex/tests/test_manifest.py @@ -31,8 +31,10 @@ def test_plugin_json_has_required_codex_metadata(self): def test_hooks_json_shape(self): hooks = json.loads((ROOT / "hooks" / "hooks.json").read_text())["hooks"] - self.assertEqual(set(hooks), {"SessionStart", "UserPromptSubmit", "Stop"}) - for event in ["SessionStart", "UserPromptSubmit", "Stop"]: + self.assertEqual( + set(hooks), {"SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"} + ) + for event in ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"]: self.assertIsInstance(hooks[event], list) self.assertNotIn("matcher", hooks[event][0]) self.assertIn("hooks", hooks[event][0]) @@ -46,6 +48,7 @@ def test_default_settings_match_spec(self): self.assertEqual(settings["agentId"], "codex") self.assertEqual(settings["sourceClient"], "codex") self.assertFalse(settings["commitOnStop"]) + self.assertTrue(settings["autoIngestAgentTimeline"]) self.assertEqual(settings["retrieveContextTurns"], 0) self.assertEqual(settings["ingestionMode"], "extract-sync") self.assertEqual(settings["ingestionMaxMessagesPerHook"], 20) diff --git a/memind-integrations/codex/tests/test_state.py b/memind-integrations/codex/tests/test_state.py index 2446f415..589c5063 100644 --- a/memind-integrations/codex/tests/test_state.py +++ b/memind-integrations/codex/tests/test_state.py @@ -45,6 +45,31 @@ def test_mark_submitted_persists_immediately(self): with store.locked("session-1") as state: self.assertTrue(state.is_submitted("a")) + def test_agent_events_are_deduplicated_and_clear_by_id(self): + with tempfile.TemporaryDirectory() as tmp: + store = SessionStateStore(Path(tmp)) + with store.locked("session-1") as state: + self.assertEqual(state.next_agent_seq(), 1) + self.assertEqual(state.next_agent_seq(), 2) + state.append_agent_event({"id": "e1", "seq": 1}) + state.append_agent_event({"id": "e1", "seq": 1}) + state.append_agent_event({"id": "e2", "seq": 2}) + state.clear_agent_events(["e1"]) + with store.locked("session-1") as state: + self.assertEqual(state.agent_events(), [{"id": "e2", "seq": 2}]) + + def test_agent_event_buffer_has_soft_cap(self): + with tempfile.TemporaryDirectory() as tmp: + store = SessionStateStore(Path(tmp)) + with store.locked("session-1") as state: + for index in range(501): + state.append_agent_event({"id": f"e{index}", "seq": index}) + with store.locked("session-1") as state: + events = state.agent_events() + self.assertEqual(len(events), 500) + self.assertEqual(events[0]["id"], "e1") + self.assertTrue(state.data["agentEventsTruncated"]) + def test_cleanup_removes_old_state(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) From cecdffe55c3c72ddf52ff6d97dc03338b9abefff Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 25 May 2026 14:53:25 +0800 Subject: [PATCH 17/54] feat(integrations): format agent memories --- .../claude-code/scripts/retrieve.py | 35 ++++++++++++++- .../claude-code/tests/test_hooks.py | 45 +++++++++++++++++++ memind-integrations/codex/scripts/retrieve.py | 35 ++++++++++++++- memind-integrations/codex/tests/test_hooks.py | 45 +++++++++++++++++++ 4 files changed, 156 insertions(+), 4 deletions(-) diff --git a/memind-integrations/claude-code/scripts/retrieve.py b/memind-integrations/claude-code/scripts/retrieve.py index 7e9980ef..7742e916 100644 --- a/memind-integrations/claude-code/scripts/retrieve.py +++ b/memind-integrations/claude-code/scripts/retrieve.py @@ -26,6 +26,14 @@ from lib.logging_utils import debug_log +AGENT_CATEGORY_SECTIONS = [ + ("playbook", "## Agent Playbooks"), + ("resolution", "## Resolved Problems"), + ("tool", "## Tool Notes"), + ("directive", "## Directives"), +] + + def _format_context(data, config): max_entries = int(config.get("retrieveMaxEntries", 8)) max_chars = int(config.get("retrieveMaxChars", 6000)) @@ -48,11 +56,20 @@ def _format_context(data, config): if selected_insights: sections.append("## Insights") sections.extend(f"- [insight:{insight.get('id')}] {insight.get('text')}" for insight in selected_insights) - if selected_items: + grouped_agent_items = _group_agent_items(selected_items) + for category, header in AGENT_CATEGORY_SECTIONS: + category_items = grouped_agent_items.get(category, []) + if category_items: + if sections: + sections.append("") + sections.append(header) + sections.extend(f"- [item:{item.get('id')}] {item.get('text')}" for item in category_items) + general_items = [item for item in selected_items if _item_category(item) not in grouped_agent_items] + if general_items: if sections: sections.append("") sections.append("## Memory Items") - sections.extend(f"- [item:{item.get('id')}] {item.get('text')}" for item in selected_items) + sections.extend(f"- [item:{item.get('id')}] {item.get('text')}" for item in general_items) degraded_notice = "" if data.get("status") == "degraded": degraded_notice = "\n[Note: Memory retrieval encountered an error. Results may be incomplete.]\n" @@ -62,6 +79,20 @@ def _format_context(data, config): return f"\n{config.get('retrievePromptPreamble') or ''}\n{body}{degraded_notice}\n" +def _item_category(item): + return str(item.get("category") or "").strip().lower() + + +def _group_agent_items(items): + agent_categories = {category for category, _header in AGENT_CATEGORY_SECTIONS} + grouped = {} + for item in items: + category = _item_category(item) + if category in agent_categories: + grouped.setdefault(category, []).append(item) + return grouped + + def main(): try: hook_input = json.loads(sys.stdin.read() or "{}") diff --git a/memind-integrations/claude-code/tests/test_hooks.py b/memind-integrations/claude-code/tests/test_hooks.py index 4b02c907..50d25f78 100644 --- a/memind-integrations/claude-code/tests/test_hooks.py +++ b/memind-integrations/claude-code/tests/test_hooks.py @@ -72,6 +72,51 @@ def test_format_context_includes_degraded_notice_without_results(self): self.assertIn("Memory retrieval encountered an error", context) + def test_format_context_groups_agent_memory_categories(self): + sys.path.insert(0, str(ROOT / "scripts")) + from retrieve import _format_context + + data = { + "items": [ + { + "id": "1", + "text": "Use npm test payment", + "category": "tool", + "metadata": {"toolName": "Bash"}, + }, + { + "id": "2", + "text": "Payment rounding mismatch was fixed", + "category": "resolution", + "metadata": {}, + }, + { + "id": "3", + "text": "When payment tests fail with rounding mismatch, inspect policy, edit calc.ts, then run npm test payment.", + "category": "playbook", + "metadata": {}, + }, + { + "id": "4", + "text": "Do not change public API", + "category": "directive", + "metadata": {}, + }, + ], + "insights": [], + } + + context = _format_context( + data, + {"retrieveMaxEntries": 8, "retrieveMaxChars": 1000, "retrievePromptPreamble": ""}, + ) + + self.assertIn("## Agent Playbooks", context) + self.assertIn("## Resolved Problems", context) + self.assertIn("## Tool Notes", context) + self.assertIn("## Directives", context) + self.assertNotIn("## Memory Items", context) + def test_ingest_without_transcript_fails_open(self): with tempfile.TemporaryDirectory() as tmp: env = {"CLAUDE_PLUGIN_ROOT": tmp, "PYTHONPATH": str(ROOT)} diff --git a/memind-integrations/codex/scripts/retrieve.py b/memind-integrations/codex/scripts/retrieve.py index b1132e71..8c47c591 100644 --- a/memind-integrations/codex/scripts/retrieve.py +++ b/memind-integrations/codex/scripts/retrieve.py @@ -27,6 +27,14 @@ from lib.logging_utils import debug_log +AGENT_CATEGORY_SECTIONS = [ + ("playbook", "## Agent Playbooks"), + ("resolution", "## Resolved Problems"), + ("tool", "## Tool Notes"), + ("directive", "## Directives"), +] + + def _format_context(data, config): max_entries = int(config.get("retrieveMaxEntries", 8)) max_chars = int(config.get("retrieveMaxChars", 6000)) @@ -49,11 +57,20 @@ def _format_context(data, config): if selected_insights: sections.append("## Insights") sections.extend(f"- [insight:{insight.get('id')}] {insight.get('text')}" for insight in selected_insights) - if selected_items: + grouped_agent_items = _group_agent_items(selected_items) + for category, header in AGENT_CATEGORY_SECTIONS: + category_items = grouped_agent_items.get(category, []) + if category_items: + if sections: + sections.append("") + sections.append(header) + sections.extend(f"- [item:{item.get('id')}] {item.get('text')}" for item in category_items) + general_items = [item for item in selected_items if _item_category(item) not in grouped_agent_items] + if general_items: if sections: sections.append("") sections.append("## Memory Items") - sections.extend(f"- [item:{item.get('id')}] {item.get('text')}" for item in selected_items) + sections.extend(f"- [item:{item.get('id')}] {item.get('text')}" for item in general_items) degraded_notice = "" if data.get("status") == "degraded": degraded_notice = "\n[Note: Memory retrieval encountered an error. Results may be incomplete.]\n" @@ -63,6 +80,20 @@ def _format_context(data, config): return f"\n{config.get('retrievePromptPreamble') or ''}\n{body}{degraded_notice}\n" +def _item_category(item): + return str(item.get("category") or "").strip().lower() + + +def _group_agent_items(items): + agent_categories = {category for category, _header in AGENT_CATEGORY_SECTIONS} + grouped = {} + for item in items: + category = _item_category(item) + if category in agent_categories: + grouped.setdefault(category, []).append(item) + return grouped + + def main(): try: hook_input = json.loads(sys.stdin.read() or "{}") diff --git a/memind-integrations/codex/tests/test_hooks.py b/memind-integrations/codex/tests/test_hooks.py index 33382654..c04e23e4 100644 --- a/memind-integrations/codex/tests/test_hooks.py +++ b/memind-integrations/codex/tests/test_hooks.py @@ -72,6 +72,51 @@ def test_format_context_includes_degraded_notice_without_results(self): self.assertIn("Memory retrieval encountered an error", context) + def test_format_context_groups_agent_memory_categories(self): + sys.path.insert(0, str(ROOT / "scripts")) + from retrieve import _format_context + + data = { + "items": [ + { + "id": "1", + "text": "Use npm test payment", + "category": "tool", + "metadata": {"toolName": "Bash"}, + }, + { + "id": "2", + "text": "Payment rounding mismatch was fixed", + "category": "resolution", + "metadata": {}, + }, + { + "id": "3", + "text": "When payment tests fail with rounding mismatch, inspect policy, edit calc.ts, then run npm test payment.", + "category": "playbook", + "metadata": {}, + }, + { + "id": "4", + "text": "Do not change public API", + "category": "directive", + "metadata": {}, + }, + ], + "insights": [], + } + + context = _format_context( + data, + {"retrieveMaxEntries": 8, "retrieveMaxChars": 1000, "retrievePromptPreamble": ""}, + ) + + self.assertIn("## Agent Playbooks", context) + self.assertIn("## Resolved Problems", context) + self.assertIn("## Tool Notes", context) + self.assertIn("## Directives", context) + self.assertNotIn("## Memory Items", context) + def test_retrieve_fail_open_when_memind_unavailable(self): with tempfile.TemporaryDirectory() as tmp: env = { From 16de61ce2e1c98cbdc838fe1d2e5c34dc9220265 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 25 May 2026 14:59:49 +0800 Subject: [PATCH 18/54] docs: describe agent timeline ingestion --- memind-clients/java/README.md | 48 +++++++++++++++ memind-clients/python/README.md | 34 +++++++++++ memind-clients/typescript/README.md | 28 ++++++++- memind-integrations/claude-code/README.md | 74 +++++++++++++++++++++-- memind-integrations/codex/README.md | 74 +++++++++++++++++++++-- 5 files changed, 248 insertions(+), 10 deletions(-) create mode 100644 memind-clients/java/README.md diff --git a/memind-clients/java/README.md b/memind-clients/java/README.md new file mode 100644 index 00000000..825b9846 --- /dev/null +++ b/memind-clients/java/README.md @@ -0,0 +1,48 @@ +# Memind Java Client + +Official Java client modules for the Memind memory engine API. + +## Agent Timeline Raw Content + +The Java client keeps extension raw-content payloads available through `MapRawContent`. Coding-agent +integrations can submit `agent_timeline` data without waiting for a dedicated Java model: + +```java +import com.openmemind.ai.client.MemindClient; +import com.openmemind.ai.client.model.common.MapRawContent; +import com.openmemind.ai.client.model.request.ExtractMemoryRequest; +import java.util.List; +import java.util.Map; + +try (MemindClient client = MemindClient.builder().baseUrl("http://localhost:8366").build()) { + var timeline = + MapRawContent.of( + "agent_timeline", + Map.of( + "sourceClient", "claude-code", + "sessionId", "session-123", + "timelineId", "session-123-agent-1-2", + "events", + List.of( + Map.of( + "id", "event-id", + "seq", 1, + "kind", "command", + "toolName", "Bash", + "command", "npm test payment", + "status", "failed", + "exitCode", 1, + "output", "{\"stdout\": \"rounding mismatch\"}")))); + + client.extract( + ExtractMemoryRequest.builder() + .userId("local__alice") + .agentId("claude-code__project_hash") + .sourceClient("claude-code") + .rawContent(timeline) + .build()); +} +``` + +The payload is sent through the normal synchronous extraction endpoint with `rawContent.type = +"agent_timeline"`. diff --git a/memind-clients/python/README.md b/memind-clients/python/README.md index 8851dbe3..c7ca28fb 100644 --- a/memind-clients/python/README.md +++ b/memind-clients/python/README.md @@ -37,6 +37,37 @@ with MemindClient(base_url="http://localhost:8080") as client: `status == "SUCCESS"` as safe to clear caller-owned retry payloads; `PARTIAL_SUCCESS` is surfaced so applications can keep or re-enqueue the original payload. +## Agent Timeline Raw Content + +Coding-agent integrations can submit tool and command activity as `agent_timeline` raw data: + +```python +response = client.memory.extract_agent_timeline( + user_id="local__alice", + agent_id="claude-code__project_hash", + source_client="claude-code", + timeline={ + "sourceClient": "claude-code", + "sessionId": "session-123", + "timelineId": "session-123-agent-1-2", + "events": [ + { + "id": "event-id", + "seq": 1, + "kind": "command", + "toolName": "Bash", + "command": "npm test payment", + "status": "failed", + "exitCode": 1, + "output": '{"stdout": "rounding mismatch"}', + } + ], + }, +) +``` + +The helper sends `rawContent.type = "agent_timeline"` through the same synchronous extraction endpoint. + ## Asynchronous Usage ```python @@ -51,6 +82,9 @@ async with AsyncMemindClient(base_url="http://localhost:8080") as client: ) ``` +The async resource also provides `await client.memory.extract_agent_timeline(...)` with the same arguments as the +synchronous helper. + ## Configuration Configuration precedence: diff --git a/memind-clients/typescript/README.md b/memind-clients/typescript/README.md index f4de36b4..24828abe 100644 --- a/memind-clients/typescript/README.md +++ b/memind-clients/typescript/README.md @@ -76,10 +76,36 @@ Message.assistant('Hi there', { timestamp: '2026-01-01T00:00:00Z' }) ## Raw Content ```ts -import { Message, RawContent } from '@openmemind/memind' +import { Message, RawContent, type AgentTimelineContent } from '@openmemind/memind' RawContent.conversation([Message.user('hi'), Message.assistant('hello')]) RawContent.map('document', { title: 'Notes', body: 'Content here' }) + +const timeline: AgentTimelineContent = { + type: 'agent_timeline', + sourceClient: 'claude-code', + sessionId: 'session-123', + timelineId: 'session-123-agent-1-2', + events: [ + { + id: 'event-id', + seq: 1, + kind: 'command', + toolName: 'Bash', + command: 'npm test payment', + status: 'failed', + exitCode: 1, + output: '{"stdout": "rounding mismatch"}', + }, + ], +} + +await client.memory.extract({ + userId: 'local__alice', + agentId: 'claude-code__project_hash', + sourceClient: 'claude-code', + rawContent: timeline, +}) ``` ## Error Handling diff --git a/memind-integrations/claude-code/README.md b/memind-integrations/claude-code/README.md index 2da86adb..f31156f2 100644 --- a/memind-integrations/claude-code/README.md +++ b/memind-integrations/claude-code/README.md @@ -13,7 +13,7 @@ The integration is intentionally small: - Uses the official Memind Python client. - No local daemon management. - No MCP dependency. -- No tool-call ingestion in v0.1. +- Captures coding-agent tool and command activity as Memind `agent_timeline` raw data. ## What It Does @@ -22,6 +22,9 @@ The integration is intentionally small: - **Ingestion**: `Stop`, `PreCompact`, and `SessionEnd` read the Claude Code transcript, filter user/assistant messages, and submit a caller-owned conversation payload through `AsyncMemindClient.memory.extract(...)`. +- **Agent timelines**: `PreToolUse` and `PostToolUse` buffer normalized tool events locally. The next + ingestion hook submits them as `rawContent.type = "agent_timeline"` so Memind can extract tool notes, + resolved problems, playbooks, and directives. - **Retry**: failed ingestion payloads are spooled under `~/.memind/claude-code/retry/` and replayed on later `SessionStart` hooks. - **Source tagging**: all requests use `sourceClient = "claude-code"` by default, so Memind can distinguish @@ -101,12 +104,13 @@ The installed hooks are: | --- | --- | ---: | --- | | `SessionStart` | `scripts/session_start.py` | 5s | Health check, replay at most one failed retry payload, and clean old state. | | `UserPromptSubmit` | `scripts/retrieve.py` | 12s | Retrieve relevant Memind context for the current user prompt. | +| `PreToolUse` | `scripts/pre_tool_use.py` | 5s | Buffer a redacted tool-start event in local session state. | +| `PostToolUse` | `scripts/post_tool_use.py` | 5s | Buffer a redacted tool-result event in local session state. | | `PreCompact` | `scripts/pre_compact.py` | 30s | Submit recent transcript messages through reliable extraction before context compaction. | | `Stop` | `scripts/ingest.py` | 15s | Submit new transcript messages through reliable extraction after a turn. | | `SessionEnd` | `scripts/session_end.py` | 10s | Submit remaining transcript messages through reliable extraction at session end. | -`Stop` is configured as async so regular turn completion stays fast. `PreToolUse` and `PostToolUse` are -intentionally unused in v0.1 because tool-call memory needs an explicit privacy and data-model design. +`Stop`, `PreToolUse`, and `PostToolUse` are configured as async so regular turn completion stays fast. ## Configuration @@ -122,6 +126,7 @@ User configuration is optional. Save overrides as `~/.memind/claude-code.json`: "agentId": "claude-code", "agentIdMode": "project", "sourceClient": "claude-code", + "autoIngestAgentTimeline": true, "ingestionMode": "extract-sync", "preCompactCommit": true, "commitOnSessionEnd": true, @@ -148,6 +153,7 @@ Settings are loaded in this order: | `sourceClient` | `claude-code` | Source marker stored with Memind data. | | `autoRetrieve` | `true` | Enables prompt-time memory retrieval. | | `autoIngest` | `true` | Enables transcript ingestion during lifecycle hooks. | +| `autoIngestAgentTimeline` | `true` | Enables `PreToolUse`/`PostToolUse` event flush as `agent_timeline` raw data. | | `retrieveStrategy` | `SIMPLE` | Memind retrieval strategy. | | `retrieveMaxEntries` | `8` | Maximum formatted memory entries injected into Claude Code. | | `retrieveMaxChars` | `6000` | Maximum injected context characters. | @@ -172,6 +178,7 @@ export MEMIND_USER_ID=local__alice export MEMIND_AGENT_ID=claude-code export MEMIND_AGENT_ID_MODE=project export MEMIND_SOURCE_CLIENT=claude-code +export MEMIND_AUTO_INGEST_AGENT_TIMELINE=true export MEMIND_INGESTION_MODE=extract-sync export MEMIND_PRE_COMPACT_COMMIT=true export MEMIND_COMMIT_ON_SESSION_END=true @@ -227,6 +234,15 @@ insights are omitted by default unless no higher-level insights are available. `retrieveContextTurns` defaults to `0`, so retrieval uses only the current prompt and does not read large transcripts. Set it to `1` or `2` if your prompts are often short, such as "fix this" or "continue". +Agent memory items are grouped separately when returned by Memind: + +```text +## Agent Playbooks +## Resolved Problems +## Tool Notes +## Directives +``` + ## Ingestion Behavior Ingestion reads Claude Code's transcript when `autoIngest = true`. @@ -245,9 +261,52 @@ The local retry spool stores the full extraction payload plus the covered messag marked submitted only after Memind returns `SUCCESS`; `PARTIAL_SUCCESS` and failures keep the payload available for later replay. +Tool and command events are buffered under `~/.memind/claude-code/state/` and flushed with the same reliable +extraction path. A typical timeline payload looks like: + +```json +{ + "userId": "local__alice", + "agentId": "claude-code__project_hash", + "sourceClient": "claude-code", + "rawContent": { + "type": "agent_timeline", + "sourceClient": "claude-code", + "sessionId": "session-123", + "timelineId": "session-123-agent-1-2", + "project": {"name": "payment-service", "rootPath": "/repo/payment-service"}, + "events": [ + { + "id": "event-id", + "seq": 1, + "kind": "command", + "toolName": "Bash", + "command": "npm test payment", + "status": "failed", + "exitCode": 1, + "output": "{\"stdout\": \"rounding mismatch\"}" + } + ] + } +} +``` + +Secrets are redacted before events are written to local state. File content capture is disabled by default; the +hook stores normalized tool metadata, commands, paths, statuses, and compact outputs. + Commit flags apply only to explicit server-buffer mode. In the default reliable mode, hooks do not issue an additional `/commit` call after successful `/extract/sync`. +## Server RawData Agent Settings + +Enable the Memind server-side rawdata-agent plugin when deploying the coding-agent memory path: + +```properties +memind.rawdata.agent.enabled=true +memind.rawdata.agent.privacy.redact-secrets=true +memind.rawdata.agent.extraction.extract-on-every-tool=false +``` + ## Verify Installation After installation, verify the integration in this order: @@ -427,6 +486,13 @@ The integration uses per-session fingerprints stored under `~/.memind/claude-cod ## Limitations -- v0.1 supports conversation memory only; tool calls are not ingested. +- Exact duplicate complete timeline windows are idempotent. +- Arbitrary overlapping partial windows are adapter responsibility in v1. +- File content capture is disabled by default. +- `rawdata-toolcall` remains supported. +- If `rawdata-toolcall` and `rawdata-agent` ingest the same tool activity, v1 may create semantically overlapping + TOOL items. This is acceptable compatibility behavior; do not add cross-plugin suppression in v1. Users who + want one canonical coding-agent path should enable `rawdata-agent` for full agent timelines and keep + `rawdata-toolcall` for pure legacy tool-call logs. - Retrieval quality depends on existing extracted Memind items and insights. - The plugin does not start or configure the Memind server. diff --git a/memind-integrations/codex/README.md b/memind-integrations/codex/README.md index 17530c49..92ab4855 100644 --- a/memind-integrations/codex/README.md +++ b/memind-integrations/codex/README.md @@ -12,7 +12,7 @@ The integration is intentionally small: - Uses the official Memind Python client. - No Codex marketplace dependency. - No overwrite of existing Codex hooks or config. -- No tool-call ingestion in v0.1. +- Captures coding-agent tool and command activity as Memind `agent_timeline` raw data. ## What It Does @@ -20,6 +20,9 @@ The integration is intentionally small: the Codex prompt as `...`. - **Ingestion**: `Stop` reads the Codex transcript, filters user/assistant messages, and submits a caller-owned conversation payload through `AsyncMemindClient.memory.extract(...)`. +- **Agent timelines**: `PreToolUse` and `PostToolUse` buffer normalized tool events locally. The next `Stop` + hook submits them as `rawContent.type = "agent_timeline"` so Memind can extract tool notes, resolved problems, + playbooks, and directives. - **Retry**: failed ingestion batches are spooled under `~/.memind/codex/retry/` and replayed on the next `SessionStart`. - **Source tagging**: all requests use `sourceClient = "codex"` by default, so Memind can distinguish Codex @@ -120,11 +123,10 @@ The installed hooks are: | --- | --- | ---: | --- | | `SessionStart` | `scripts/session_start.py` | 5s | Replay at most one failed ingestion batch and clean old state. | | `UserPromptSubmit` | `scripts/retrieve.py` | 12s | Retrieve relevant Memind context for the current user prompt. | +| `PreToolUse` | `scripts/pre_tool_use.py` | 5s | Buffer a redacted tool-start event in local session state. | +| `PostToolUse` | `scripts/post_tool_use.py` | 5s | Buffer a redacted tool-result event in local session state. | | `Stop` | `scripts/ingest.py` | 15s | Submit new Codex transcript messages through reliable extraction. | -`PreToolUse`, `PostToolUse`, and `PermissionRequest` are intentionally unused in v0.1. Tool-call memory can be -added later after the data model and privacy behavior are explicitly designed. - ## Configuration The default configuration works with a local Memind server at `http://127.0.0.1:8366`. @@ -139,6 +141,7 @@ User configuration is optional. Save overrides as `~/.memind/codex.json`: "agentId": "codex", "agentIdMode": "project", "sourceClient": "codex", + "autoIngestAgentTimeline": true, "ingestionMode": "extract-sync", "commitOnStop": false, "retrieveContextTurns": 0 @@ -163,6 +166,7 @@ Settings are loaded in this order: | `sourceClient` | `codex` | Source marker stored with Memind data. | | `autoRetrieve` | `true` | Enables prompt-time memory retrieval. | | `autoIngest` | `true` | Enables transcript ingestion after Codex turns. | +| `autoIngestAgentTimeline` | `true` | Enables `PreToolUse`/`PostToolUse` event flush as `agent_timeline` raw data. | | `commitOnStop` | `false` | Compatibility flag for server-buffer ingestion mode; ignored by the default reliable mode. | | `retrieveStrategy` | `SIMPLE` | Memind retrieval strategy. | | `retrieveMaxEntries` | `8` | Maximum formatted memory entries injected into Codex. | @@ -185,6 +189,7 @@ export MEMIND_USER_ID=local__alice export MEMIND_AGENT_ID=codex export MEMIND_AGENT_ID_MODE=project export MEMIND_SOURCE_CLIENT=codex +export MEMIND_AUTO_INGEST_AGENT_TIMELINE=true export MEMIND_COMMIT_ON_STOP=false export MEMIND_RETRIEVE_CONTEXT_TURNS=0 export MEMIND_DEBUG=true @@ -237,6 +242,15 @@ insights are omitted by default unless no higher-level insights are available. `retrieveContextTurns` defaults to `0`, so retrieval uses only the current prompt and does not read large transcripts. Set it to `1` or `2` if your prompts are often short, such as "fix this" or "continue". +Agent memory items are grouped separately when returned by Memind: + +```text +## Agent Playbooks +## Resolved Problems +## Tool Notes +## Directives +``` + ## Ingestion Behavior Ingestion runs after each Codex turn when `autoIngest = true`. @@ -255,9 +269,52 @@ The local retry spool stores the full extraction payload plus the covered messag marked submitted only after Memind returns `SUCCESS`; `PARTIAL_SUCCESS` and failures keep the payload available for later replay. +Tool and command events are buffered under `~/.memind/codex/state/` and flushed with the same reliable extraction +path. A typical timeline payload looks like: + +```json +{ + "userId": "local__alice", + "agentId": "codex__project_hash", + "sourceClient": "codex", + "rawContent": { + "type": "agent_timeline", + "sourceClient": "codex", + "sessionId": "session-123", + "timelineId": "session-123-agent-1-2", + "project": {"name": "payment-service", "rootPath": "/repo/payment-service"}, + "events": [ + { + "id": "event-id", + "seq": 1, + "kind": "command", + "toolName": "Bash", + "command": "npm test payment", + "status": "failed", + "exitCode": 1, + "output": "{\"stdout\": \"rounding mismatch\"}" + } + ] + } +} +``` + +Secrets are redacted before events are written to local state. File content capture is disabled by default; the +hook stores normalized tool metadata, commands, paths, statuses, and compact outputs. + Commit flags apply only to explicit server-buffer mode. In the default reliable mode, hooks do not issue an additional `/commit` call after successful `/extract/sync`. +## Server RawData Agent Settings + +Enable the Memind server-side rawdata-agent plugin when deploying the coding-agent memory path: + +```properties +memind.rawdata.agent.enabled=true +memind.rawdata.agent.privacy.redact-secrets=true +memind.rawdata.agent.extraction.extract-on-every-tool=false +``` + ## Verify Installation After installation, verify the integration in this order: @@ -386,7 +443,14 @@ The integration uses per-session fingerprints stored under `~/.memind/codex/stat ## Limitations -- v0.1 supports conversation memory only; tool calls are not ingested. +- Exact duplicate complete timeline windows are idempotent. +- Arbitrary overlapping partial windows are adapter responsibility in v1. +- File content capture is disabled by default. +- `rawdata-toolcall` remains supported. +- If `rawdata-toolcall` and `rawdata-agent` ingest the same tool activity, v1 may create semantically overlapping + TOOL items. This is acceptable compatibility behavior; do not add cross-plugin suppression in v1. Users who + want one canonical coding-agent path should enable `rawdata-agent` for full agent timelines and keep + `rawdata-toolcall` for pure legacy tool-call logs. - Retrieval quality depends on existing extracted Memind items and insights. - `commitOnStop` applies only to compatibility server-buffer ingestion mode and is ignored by the default reliable extraction mode. From 421e3b072d98929427bac86d6ae9ac5045a58c16 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 25 May 2026 15:30:36 +0800 Subject: [PATCH 19/54] test(server): cover agent timeline open api --- .../mapper/item/AdminItemQueryMapper.java | 31 +- .../AgentTimelineOpenApiIntegrationTest.java | 471 ++++++++++++++++++ 2 files changed, 500 insertions(+), 2 deletions(-) create mode 100644 memind-server/src/test/java/com/openmemind/ai/memory/server/AgentTimelineOpenApiIntegrationTest.java diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/mapper/item/AdminItemQueryMapper.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/mapper/item/AdminItemQueryMapper.java index cefa4337..0cbcc345 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/mapper/item/AdminItemQueryMapper.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/mapper/item/AdminItemQueryMapper.java @@ -15,6 +15,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.openmemind.ai.memory.core.data.enums.MemoryCategory; import com.openmemind.ai.memory.plugin.store.mybatis.dataobject.MemoryItemDO; import com.openmemind.ai.memory.plugin.store.mybatis.mapper.MemoryItemMapper; import com.openmemind.ai.memory.server.domain.common.PageResponse; @@ -22,6 +23,7 @@ import com.openmemind.ai.memory.server.domain.item.view.AdminItemView; import java.util.Collection; import java.util.List; +import java.util.Locale; import java.util.Optional; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -60,7 +62,7 @@ public PageResponse page(ItemPageQuery query) { wrapper.eq(MemoryItemDO::getScope, query.scope()); } if (StringUtils.hasText(query.category())) { - wrapper.eq(MemoryItemDO::getCategory, query.category()); + wrapper.in(MemoryItemDO::getCategory, categoryFilterValues(query.category())); } if (StringUtils.hasText(query.type())) { wrapper.eq(MemoryItemDO::getType, query.type()); @@ -116,7 +118,7 @@ private static AdminItemView toView(MemoryItemDO dataObject) { dataObject.getMemoryId(), dataObject.getContent(), dataObject.getScope(), - dataObject.getCategory(), + normalizeCategory(dataObject.getCategory()), dataObject.getVectorId(), dataObject.getRawDataId(), dataObject.getContentHash(), @@ -129,4 +131,29 @@ private static AdminItemView toView(MemoryItemDO dataObject) { dataObject.getCreatedAt(), dataObject.getUpdatedAt()); } + + private static List categoryFilterValues(String category) { + if (!StringUtils.hasText(category)) { + return List.of(); + } + return parseCategory(category) + .map(value -> List.of(value.name(), value.categoryName())) + .orElseGet(() -> List.of(category)); + } + + private static String normalizeCategory(String category) { + return parseCategory(category).map(MemoryCategory::categoryName).orElse(category); + } + + private static Optional parseCategory(String category) { + if (!StringUtils.hasText(category)) { + return Optional.empty(); + } + String trimmed = category.trim(); + try { + return Optional.of(MemoryCategory.valueOf(trimmed.toUpperCase(Locale.ROOT))); + } catch (IllegalArgumentException e) { + return MemoryCategory.byName(trimmed.toLowerCase(Locale.ROOT)); + } + } } diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/AgentTimelineOpenApiIntegrationTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/AgentTimelineOpenApiIntegrationTest.java new file mode 100644 index 00000000..6a42587c --- /dev/null +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/AgentTimelineOpenApiIntegrationTest.java @@ -0,0 +1,471 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.server; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.hasItem; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.openmemind.ai.memory.core.builder.ExtractionCommonOptions; +import com.openmemind.ai.memory.core.builder.ExtractionOptions; +import com.openmemind.ai.memory.core.builder.InsightExtractionOptions; +import com.openmemind.ai.memory.core.builder.ItemExtractionOptions; +import com.openmemind.ai.memory.core.builder.ItemGraphOptions; +import com.openmemind.ai.memory.core.builder.MemoryBuildOptions; +import com.openmemind.ai.memory.core.builder.PromptBudgetOptions; +import com.openmemind.ai.memory.core.builder.RawDataExtractionOptions; +import com.openmemind.ai.memory.core.data.MemoryId; +import com.openmemind.ai.memory.core.extraction.insight.scheduler.InsightBuildConfig; +import com.openmemind.ai.memory.core.llm.ChatMessage; +import com.openmemind.ai.memory.core.llm.StructuredChatClient; +import com.openmemind.ai.memory.core.utils.JsonUtils; +import com.openmemind.ai.memory.core.vector.MemoryVector; +import com.openmemind.ai.memory.core.vector.VectorSearchResult; +import com.openmemind.ai.memory.server.runtime.MemoryRuntimeFactory; +import com.openmemind.ai.memory.server.runtime.MemoryRuntimeManager; +import com.openmemind.ai.memory.server.service.config.MemoryOptionService; +import com.openmemind.ai.memory.server.support.NoopRuntimeTestConfiguration; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +@SpringBootTest( + classes = { + MemindServerApplication.class, + AgentTimelineOpenApiIntegrationTest.TestRuntimeConfiguration.class + }) +class AgentTimelineOpenApiIntegrationTest { + + private static final Path DB_PATH = + Path.of("target", "memind-agent-timeline-openapi-" + UUID.randomUUID() + ".db") + .toAbsolutePath(); + + private final ObjectMapper objectMapper = JsonUtils.mapper(); + + @Autowired private WebApplicationContext webApplicationContext; + + @Autowired private JdbcTemplate jdbcTemplate; + + @Autowired private MemoryRuntimeFactory memoryRuntimeFactory; + + @Autowired private MemoryRuntimeManager runtimeManager; + + @Autowired private MemoryOptionService memoryOptionService; + + @Autowired private TestMemoryVector memoryVector; + + private MockMvc mockMvc; + + @DynamicPropertySource + static void registerProperties(DynamicPropertyRegistry registry) { + registry.add("spring.main.web-application-type", () -> "servlet"); + registry.add("spring.datasource.url", () -> "jdbc:sqlite:" + DB_PATH); + registry.add("spring.datasource.driver-class-name", () -> "org.sqlite.JDBC"); + registry.add("memind.store.init-schema", () -> "true"); + registry.add( + "spring.autoconfigure.exclude", + () -> NoopRuntimeTestConfiguration.SPRING_AI_AUTOCONFIG_EXCLUDES); + } + + @BeforeEach + void setUp() { + this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + clearDatabase(); + memoryVector.clear(); + + MemoryRuntimeFactory.CreationResult created = + memoryRuntimeFactory.create(testMemoryOptions()); + runtimeManager.swap(created.memory(), created.effectiveOptions(), 1L); + memoryOptionService.getCurrent(); + } + + @AfterAll + static void cleanUpDatabase() throws IOException { + Files.deleteIfExists(DB_PATH); + } + + @Test + void syncExtractPersistsAgentEpisodeRawDataAndAgentItems() throws Exception { + mockMvc.perform( + post("/open/v1/memory/sync/extract") + .contentType(APPLICATION_JSON) + .content(paymentTimelineRequest())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.data.status").value("SUCCESS")) + .andExpect(jsonPath("$.data.rawDataIds.length()").value(1)) + .andExpect(jsonPath("$.data.itemIds.length()", greaterThanOrEqualTo(1))); + + mockMvc.perform(get("/admin/v1/items").queryParam("userId", "u").queryParam("agentId", "a")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.data.page.totalItems", greaterThanOrEqualTo(1))) + .andExpect(jsonPath("$.data.items[*].category", hasItem("tool"))) + .andExpect(jsonPath("$.data.items[*].category", hasItem("resolution"))); + + mockMvc.perform( + get("/admin/v1/items") + .queryParam("userId", "u") + .queryParam("agentId", "a") + .queryParam("category", "tool")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.data.page.totalItems").value(1)) + .andExpect(jsonPath("$.data.items[0].category").value("tool")); + + mockMvc.perform( + get("/admin/v1/raw-data") + .queryParam("userId", "u") + .queryParam("agentId", "a")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.data.page.totalItems").value(1)) + .andExpect(jsonPath("$.data.items[0].metadata.segmentType").value("agent_episode")) + .andExpect( + jsonPath("$.data.items[0].segment.metadata.segmentType") + .value("agent_episode")); + } + + @Test + void duplicateTimelineSubmissionDoesNotIncreaseDurableItemCount() throws Exception { + extractPaymentTimeline(); + int itemCountAfterFirstSubmit = itemCount(); + + extractPaymentTimeline(); + + assertThat(itemCount()).isEqualTo(itemCountAfterFirstSubmit); + } + + @Test + void retrieveReturnsAgentToolCategoryAndCommandsMetadata() throws Exception { + extractPaymentTimeline(); + + MvcResult result = + mockMvc.perform( + post("/open/v1/memory/retrieve") + .contentType(APPLICATION_JSON) + .content( + """ + { + "userId": "u", + "agentId": "a", + "query": "How should payment tests be validated?", + "strategy": "SIMPLE" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.data.status").value("success")) + .andExpect(jsonPath("$.data.items.length()", greaterThanOrEqualTo(1))) + .andReturn(); + + JsonNode items = + objectMapper + .readTree(result.getResponse().getContentAsString()) + .path("data") + .path("items"); + assertThat(items).anySatisfy(AgentTimelineOpenApiIntegrationTest::assertToolCommandItem); + } + + private void extractPaymentTimeline() throws Exception { + mockMvc.perform( + post("/open/v1/memory/sync/extract") + .contentType(APPLICATION_JSON) + .content(paymentTimelineRequest())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("SUCCESS")); + } + + private static void assertToolCommandItem(JsonNode item) { + assertThat(item.path("category").asText()).isEqualTo("tool"); + assertThat(item.path("metadata").path("commands")) + .anySatisfy(command -> assertThat(command.asText()).isEqualTo("npm test payment")); + } + + private int itemCount() { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM memory_item WHERE memory_id = ? AND deleted = 0", + Integer.class, + "u:a"); + } + + private void clearDatabase() { + jdbcTemplate.update("DELETE FROM memory_graph_alias_batch_receipt"); + jdbcTemplate.update("DELETE FROM memory_entity_cooccurrence"); + jdbcTemplate.update("DELETE FROM memory_item_link"); + jdbcTemplate.update("DELETE FROM memory_item_entity_mention"); + jdbcTemplate.update("DELETE FROM memory_graph_entity_alias"); + jdbcTemplate.update("DELETE FROM memory_graph_entity"); + jdbcTemplate.update("DELETE FROM memory_item_graph_batch"); + jdbcTemplate.update("DELETE FROM memory_insight_buffer"); + jdbcTemplate.update("DELETE FROM memory_conversation_buffer"); + jdbcTemplate.update("DELETE FROM thread_intake_outbox"); + jdbcTemplate.update("DELETE FROM memory_thread_event"); + jdbcTemplate.update("DELETE FROM memory_thread_membership"); + jdbcTemplate.update("DELETE FROM memory_thread_runtime"); + jdbcTemplate.update("DELETE FROM memory_thread"); + jdbcTemplate.update("DELETE FROM memory_insight"); + jdbcTemplate.update("DELETE FROM memory_item"); + jdbcTemplate.update("DELETE FROM memory_raw_data"); + jdbcTemplate.update("DELETE FROM insight_fts"); + jdbcTemplate.update("DELETE FROM item_fts"); + jdbcTemplate.update("DELETE FROM raw_data_fts"); + jdbcTemplate.update("DELETE FROM memind_server_runtime_config"); + } + + private static MemoryBuildOptions testMemoryOptions() { + return MemoryBuildOptions.builder() + .extraction( + new ExtractionOptions( + ExtractionCommonOptions.defaults(), + RawDataExtractionOptions.defaults(), + new ItemExtractionOptions( + false, + PromptBudgetOptions.defaults(), + ItemGraphOptions.defaults().withEnabled(false)), + new InsightExtractionOptions( + false, new InsightBuildConfig(100, 100, 100, 100)))) + .build(); + } + + private static String paymentTimelineRequest() { + return """ + { + "userId": "u", + "agentId": "a", + "sourceClient": "claude-code", + "rawContent": { + "type": "agent_timeline", + "sourceClient": "claude-code", + "sessionId": "s", + "timelineId": "t", + "project": { + "name": "payments-api", + "rootPath": "/Users/alice/work/payments-api" + }, + "events": [ + { + "id": "e1", + "seq": 1, + "kind": "user_prompt", + "text": "Fix payment tests", + "occurredAt": "2026-05-24T10:00:00Z" + }, + { + "id": "e2", + "seq": 2, + "kind": "command", + "toolName": "Bash", + "command": "npm test payment", + "status": "failed", + "output": "rounding mismatch", + "metadata": {"failureSignal": "rounding mismatch"}, + "occurredAt": "2026-05-24T10:01:00Z" + }, + { + "id": "e3", + "seq": 3, + "kind": "file_edit", + "path": "src/payment/calc.ts", + "operation": "modify", + "occurredAt": "2026-05-24T10:02:00Z" + }, + { + "id": "e4", + "seq": 4, + "kind": "command", + "toolName": "Bash", + "command": "npm test payment", + "status": "success", + "occurredAt": "2026-05-24T10:03:00Z" + }, + { + "id": "e5", + "seq": 5, + "kind": "stop", + "occurredAt": "2026-05-24T10:04:00Z" + } + ] + } + } + """; + } + + @org.springframework.boot.test.context.TestConfiguration(proxyBeanMethods = false) + static class TestRuntimeConfiguration { + + @Bean + StructuredChatClient structuredChatClient() { + return new NoopStructuredChatClient(); + } + + @Bean + @Primary + TestMemoryVector memoryVector() { + return new TestMemoryVector(); + } + } + + private static final class NoopStructuredChatClient implements StructuredChatClient { + + @Override + public Mono call(List messages) { + return Mono.empty(); + } + + @Override + public Mono call(List messages, Class responseType) { + return Mono.empty(); + } + } + + static final class TestMemoryVector implements MemoryVector { + + private final AtomicInteger sequence = new AtomicInteger(); + private final Map vectors = new ConcurrentHashMap<>(); + + void clear() { + vectors.clear(); + sequence.set(0); + } + + @Override + public Mono store(MemoryId memoryId, String text, Map metadata) { + String vectorId = "test-vector-" + sequence.incrementAndGet(); + vectors.put(vectorId, new StoredVector(memoryId.toIdentifier(), vectorId, text)); + return Mono.just(vectorId); + } + + @Override + public Mono> storeBatch( + MemoryId memoryId, List texts, List> metadataList) { + return Flux.fromIterable(texts) + .concatMap(text -> store(memoryId, text, Map.of())) + .collectList(); + } + + @Override + public Mono delete(MemoryId memoryId, String vectorId) { + vectors.remove(vectorId); + return Mono.empty(); + } + + @Override + public Mono deleteBatch(MemoryId memoryId, List vectorIds) { + vectorIds.forEach(vectors::remove); + return Mono.empty(); + } + + @Override + public Flux search(MemoryId memoryId, String query, int topK) { + return search(memoryId, query, topK, Map.of()); + } + + @Override + public Flux search( + MemoryId memoryId, String query, int topK, Map filter) { + String memoryKey = memoryId.toIdentifier(); + return Flux.fromIterable( + vectors.values().stream() + .filter(vector -> vector.memoryId().equals(memoryKey)) + .map(vector -> toSearchResult(vector, query)) + .filter(result -> result.score() > 0.0f) + .sorted(Comparator.comparing(VectorSearchResult::score).reversed()) + .limit(topK) + .toList()); + } + + @Override + public Mono> embed(String text) { + return Mono.just(embedding(text)); + } + + @Override + public Mono>> embedAll(List texts) { + return Mono.just(texts.stream().map(TestMemoryVector::embedding).toList()); + } + + private static VectorSearchResult toSearchResult(StoredVector vector, String query) { + return new VectorSearchResult( + vector.vectorId(), vector.text(), lexicalScore(query, vector.text()), Map.of()); + } + + private static float lexicalScore(String query, String text) { + List queryTokens = tokens(query); + List textTokens = tokens(text); + if (queryTokens.isEmpty() || textTokens.isEmpty()) { + return 0.0f; + } + long matches = queryTokens.stream().filter(textTokens::contains).distinct().count(); + if (matches == 0 && text.toLowerCase(Locale.ROOT).contains("npm test payment")) { + return 0.65f; + } + return matches == 0 ? 0.0f : Math.min(0.99f, 0.55f + (matches * 0.1f)); + } + + private static List tokens(String value) { + if (value == null || value.isBlank()) { + return List.of(); + } + List result = new ArrayList<>(); + for (String token : value.toLowerCase(Locale.ROOT).split("[^a-z0-9]+")) { + if (token.length() >= 3) { + result.add(token); + } + } + return result; + } + + private static List embedding(String text) { + int hash = text == null ? 0 : text.hashCode(); + return IntStream.range(0, 8) + .mapToObj(index -> ((hash >> (index * 3)) & 0x0F) / 15.0f) + .toList(); + } + + private record StoredVector(String memoryId, String vectorId, String text) {} + } +} From a4ff41347b93a2ffe36152dbe7defc9924a5b26b Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 25 May 2026 15:38:26 +0800 Subject: [PATCH 20/54] test(eval): add rawdata-agent fixtures --- evaluation/rawdata-agent/README.md | 31 +++ .../rawdata-agent/fixtures/auth-jwt-fix.json | 97 ++++++++ .../fixtures/payment-rounding-fix.json | 104 +++++++++ .../fixtures/project-directive.json | 71 ++++++ evaluation/rawdata-agent/run-fixtures.py | 220 ++++++++++++++++++ 5 files changed, 523 insertions(+) create mode 100644 evaluation/rawdata-agent/README.md create mode 100644 evaluation/rawdata-agent/fixtures/auth-jwt-fix.json create mode 100644 evaluation/rawdata-agent/fixtures/payment-rounding-fix.json create mode 100644 evaluation/rawdata-agent/fixtures/project-directive.json create mode 100644 evaluation/rawdata-agent/run-fixtures.py diff --git a/evaluation/rawdata-agent/README.md b/evaluation/rawdata-agent/README.md new file mode 100644 index 00000000..eb780f5f --- /dev/null +++ b/evaluation/rawdata-agent/README.md @@ -0,0 +1,31 @@ +# rawdata-agent Evaluation Fixtures + +This directory contains small black-box fixtures for validating Memind's +`agent_timeline` ingestion path against a running `memind-server`. + +Each fixture is a self-contained JSON document: + +- `request`: payload for `POST /open/v1/memory/sync/extract`. +- `expectations.expectedCategories`: item categories expected from the first extraction. +- `expectations.queries`: retrieval checks for `POST /open/v1/memory/retrieve`. +- `expectations.optionalCategories`: categories that may appear when LLM extraction is enabled, + but are not required for deterministic v1 acceptance. + +The fixtures are intentionally deterministic-friendly. They validate the core +coding-agent memory path without depending on an LLM: + +- command/tool reuse memory, such as `npm test payment`. +- resolution memory from a failed command, file edit, and later matching success. +- duplicate submission behavior through stable raw content and item hashes. + +## Run + +Start `memind-server` on the default port, then run: + +```bash +python3 evaluation/rawdata-agent/run-fixtures.py --base-url http://127.0.0.1:8366 +``` + +The runner exits non-zero when the server is unreachable or any fixture fails. +It prints a duplicate item rate for each fixture so regressions in idempotency +are visible during manual evaluation. diff --git a/evaluation/rawdata-agent/fixtures/auth-jwt-fix.json b/evaluation/rawdata-agent/fixtures/auth-jwt-fix.json new file mode 100644 index 00000000..a4d7bbfa --- /dev/null +++ b/evaluation/rawdata-agent/fixtures/auth-jwt-fix.json @@ -0,0 +1,97 @@ +{ + "name": "auth-jwt-fix", + "description": "A coding-agent episode where a JWT validation regression is fixed and validated.", + "request": { + "userId": "eval-agent-user-auth", + "agentId": "eval-coding-agent", + "sourceClient": "claude-code", + "rawContent": { + "type": "agent_timeline", + "sourceClient": "claude-code", + "sourceVersion": "eval-fixture", + "sessionId": "eval-auth-jwt-session", + "timelineId": "eval-auth-jwt-timeline", + "project": { + "name": "identity-service", + "rootPath": "/workspace/identity-service", + "git": { + "branch": "fix/jwt-clock-skew", + "commit": "8a12c0ffee" + } + }, + "events": [ + { + "id": "auth-e1", + "seq": 1, + "kind": "user_prompt", + "text": "Fix the expired JWT acceptance regression in the auth middleware.", + "occurredAt": "2026-05-24T09:00:00Z" + }, + { + "id": "auth-e2", + "seq": 2, + "kind": "command", + "toolName": "Bash", + "command": "npm test auth-jwt", + "status": "failed", + "exitCode": 1, + "output": "TokenValidationError: expired JWT accepted beyond allowed clock skew", + "metadata": { + "failureSignal": "expired JWT accepted beyond allowed clock skew" + }, + "occurredAt": "2026-05-24T09:01:00Z" + }, + { + "id": "auth-e3", + "seq": 3, + "kind": "file_edit", + "path": "src/auth/jwt_validator.ts", + "operation": "modify", + "occurredAt": "2026-05-24T09:04:00Z" + }, + { + "id": "auth-e4", + "seq": 4, + "kind": "file_edit", + "path": "test/auth/jwt_validator.test.ts", + "operation": "modify", + "occurredAt": "2026-05-24T09:06:00Z" + }, + { + "id": "auth-e5", + "seq": 5, + "kind": "command", + "toolName": "Bash", + "command": "npm test auth-jwt", + "status": "success", + "exitCode": 0, + "output": "auth-jwt tests passed", + "occurredAt": "2026-05-24T09:08:00Z" + }, + { + "id": "auth-e6", + "seq": 6, + "kind": "task_completed", + "status": "success", + "text": "JWT expiry validation now rejects tokens beyond the allowed clock skew.", + "occurredAt": "2026-05-24T09:09:00Z" + } + ] + } + }, + "expectations": { + "expectedCategories": ["tool", "resolution"], + "queries": [ + { + "query": "How should auth JWT fixes be validated?", + "mustContain": ["npm test auth-jwt"], + "expectedCategories": ["tool"] + }, + { + "query": "What fixed expired JWT acceptance?", + "mustContain": ["expired JWT accepted beyond allowed clock skew"], + "expectedCategories": ["resolution"] + } + ] + } +} diff --git a/evaluation/rawdata-agent/fixtures/payment-rounding-fix.json b/evaluation/rawdata-agent/fixtures/payment-rounding-fix.json new file mode 100644 index 00000000..1119d296 --- /dev/null +++ b/evaluation/rawdata-agent/fixtures/payment-rounding-fix.json @@ -0,0 +1,104 @@ +{ + "name": "payment-rounding-fix", + "description": "A payment rounding failure is fixed, retested, and stored as tool plus resolution memory.", + "request": { + "userId": "eval-agent-user-payment", + "agentId": "eval-coding-agent", + "sourceClient": "codex", + "rawContent": { + "type": "agent_timeline", + "sourceClient": "codex", + "sourceVersion": "eval-fixture", + "sessionId": "eval-payment-session", + "timelineId": "eval-payment-timeline", + "project": { + "name": "payments-api", + "rootPath": "/workspace/payments-api", + "git": { + "branch": "fix/payment-rounding", + "commit": "9b34fade12" + } + }, + "events": [ + { + "id": "payment-e1", + "seq": 1, + "kind": "user_prompt", + "text": "Fix the payment calculation rounding mismatch.", + "occurredAt": "2026-05-24T10:00:00Z" + }, + { + "id": "payment-e2", + "seq": 2, + "kind": "command", + "toolName": "Shell", + "command": "npm test payment", + "status": "failed", + "exitCode": 1, + "output": "rounding mismatch on half-cent tax calculation", + "metadata": { + "failureSignal": "rounding mismatch on half-cent tax calculation" + }, + "occurredAt": "2026-05-24T10:01:00Z" + }, + { + "id": "payment-e3", + "seq": 3, + "kind": "file_read", + "path": "src/payment/calc.ts", + "operation": "read", + "occurredAt": "2026-05-24T10:02:00Z" + }, + { + "id": "payment-e4", + "seq": 4, + "kind": "file_edit", + "path": "src/payment/calc.ts", + "operation": "modify", + "occurredAt": "2026-05-24T10:03:00Z" + }, + { + "id": "payment-e5", + "seq": 5, + "kind": "file_edit", + "path": "test/payment/calc.test.ts", + "operation": "modify", + "occurredAt": "2026-05-24T10:05:00Z" + }, + { + "id": "payment-e6", + "seq": 6, + "kind": "command", + "toolName": "Shell", + "command": "npm test payment", + "status": "success", + "exitCode": 0, + "output": "payment tests passed", + "occurredAt": "2026-05-24T10:07:00Z" + }, + { + "id": "payment-e7", + "seq": 7, + "kind": "stop", + "status": "success", + "occurredAt": "2026-05-24T10:08:00Z" + } + ] + } + }, + "expectations": { + "expectedCategories": ["tool", "resolution"], + "queries": [ + { + "query": "How do I validate payment calculation changes?", + "mustContain": ["npm test payment"], + "expectedCategories": ["tool"] + }, + { + "query": "What resolved the payment rounding mismatch?", + "mustContain": ["rounding mismatch on half-cent tax calculation"], + "expectedCategories": ["resolution"] + } + ] + } +} diff --git a/evaluation/rawdata-agent/fixtures/project-directive.json b/evaluation/rawdata-agent/fixtures/project-directive.json new file mode 100644 index 00000000..8094122e --- /dev/null +++ b/evaluation/rawdata-agent/fixtures/project-directive.json @@ -0,0 +1,71 @@ +{ + "name": "project-directive", + "description": "A project-scoped coding-agent instruction that should be recoverable as agent memory.", + "request": { + "userId": "eval-agent-user-directive", + "agentId": "eval-coding-agent", + "sourceClient": "claude-code", + "rawContent": { + "type": "agent_timeline", + "sourceClient": "claude-code", + "sourceVersion": "eval-fixture", + "sessionId": "eval-directive-session", + "timelineId": "eval-directive-timeline", + "project": { + "name": "ledger-service", + "rootPath": "/workspace/ledger-service", + "git": { + "branch": "main", + "commit": "7c77directive" + } + }, + "events": [ + { + "id": "directive-e1", + "seq": 1, + "kind": "user_prompt", + "text": "For this repository, always run go test ./... before reporting completion.", + "occurredAt": "2026-05-24T11:00:00Z" + }, + { + "id": "directive-e2", + "seq": 2, + "kind": "file_edit", + "path": "README.md", + "operation": "modify", + "occurredAt": "2026-05-24T11:01:00Z" + }, + { + "id": "directive-e3", + "seq": 3, + "kind": "command", + "toolName": "Bash", + "command": "go test ./...", + "status": "success", + "exitCode": 0, + "output": "ok ./...", + "occurredAt": "2026-05-24T11:03:00Z" + }, + { + "id": "directive-e4", + "seq": 4, + "kind": "task_completed", + "status": "success", + "text": "Updated README and validated with go test ./...", + "occurredAt": "2026-05-24T11:04:00Z" + } + ] + } + }, + "expectations": { + "expectedCategories": ["tool"], + "queries": [ + { + "query": "What command should be run before reporting completion in ledger-service?", + "mustContain": ["go test ./..."], + "expectedCategories": ["tool"] + } + ], + "optionalCategories": ["directive"] + } +} diff --git a/evaluation/rawdata-agent/run-fixtures.py b/evaluation/rawdata-agent/run-fixtures.py new file mode 100644 index 00000000..6115d29e --- /dev/null +++ b/evaluation/rawdata-agent/run-fixtures.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""Run rawdata-agent fixtures against a running memind-server.""" + +from __future__ import annotations + +import argparse +import copy +import json +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +DEFAULT_BASE_URL = "http://127.0.0.1:8366" +FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" + + +class FixtureError(RuntimeError): + pass + + +@dataclass(frozen=True) +class HttpClient: + base_url: str + timeout: float + + def get(self, path: str, params: dict[str, str] | None = None) -> dict[str, Any]: + if params: + path = path + "?" + urllib.parse.urlencode(params) + return self._request("GET", path, None) + + def post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: + return self._request("POST", path, payload) + + def _request(self, method: str, path: str, payload: dict[str, Any] | None) -> dict[str, Any]: + url = self.base_url.rstrip("/") + path + body = None if payload is None else json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + url, + data=body, + method=method, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + text = response.read().decode("utf-8") + return json.loads(text) if text else {} + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise FixtureError(f"{method} {url} failed with HTTP {exc.code}: {detail}") from exc + except urllib.error.URLError as exc: + raise FixtureError( + f"Cannot reach memind-server at {self.base_url}. " + "Start the server first or pass --base-url." + ) from exc + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Run rawdata-agent evaluation fixtures against memind-server." + ) + parser.add_argument("--base-url", default=DEFAULT_BASE_URL) + parser.add_argument("--fixtures-dir", type=Path, default=FIXTURE_DIR) + parser.add_argument("--timeout", type=float, default=20.0) + parser.add_argument( + "--run-id", + default=f"run-{int(time.time())}", + help="Suffix used to isolate fixture user ids. Use an empty value to keep fixture ids.", + ) + args = parser.parse_args() + + client = HttpClient(args.base_url, args.timeout) + try: + client.get("/open/v1/health") + fixtures = load_fixtures(args.fixtures_dir) + for fixture in fixtures: + run_fixture(client, isolate_fixture(fixture, args.run_id)) + except FixtureError as exc: + print(f"FAILED: {exc}", file=sys.stderr) + return 1 + + print(f"PASS: {len(fixtures)} rawdata-agent fixture(s) passed") + return 0 + + +def load_fixtures(fixtures_dir: Path) -> list[dict[str, Any]]: + if not fixtures_dir.exists(): + raise FixtureError(f"Fixtures directory does not exist: {fixtures_dir}") + fixtures = [] + for path in sorted(fixtures_dir.glob("*.json")): + try: + fixtures.append(json.loads(path.read_text(encoding="utf-8"))) + except json.JSONDecodeError as exc: + raise FixtureError(f"Invalid JSON in {path}: {exc}") from exc + if not fixtures: + raise FixtureError(f"No fixture JSON files found in {fixtures_dir}") + return fixtures + + +def isolate_fixture(fixture: dict[str, Any], run_id: str) -> dict[str, Any]: + if not run_id: + return fixture + isolated = copy.deepcopy(fixture) + request = isolated.get("request") or {} + if "userId" in request: + request["userId"] = f"{request['userId']}-{run_id}" + return isolated + + +def run_fixture(client: HttpClient, fixture: dict[str, Any]) -> None: + name = fixture.get("name") or "" + request = fixture.get("request") + expectations = fixture.get("expectations") or {} + if not isinstance(request, dict): + raise FixtureError(f"{name}: fixture request must be an object") + + first = extract(client, request, name) + expected_categories = list(expectations.get("expectedCategories") or []) + assert_categories(client, name, request, first, expected_categories) + + second = extract(client, request, name) + first_count = len(first.get("itemIds") or []) + second_count = len(second.get("itemIds") or []) + duplicate_rate = 0.0 if first_count == 0 else 1.0 - (second_count / first_count) + print(f"{name}: duplicate item rate {duplicate_rate:.2%}") + + for query_expectation in expectations.get("queries") or []: + assert_retrieve(client, request, query_expectation, name) + + +def extract(client: HttpClient, payload: dict[str, Any], fixture_name: str) -> dict[str, Any]: + response = client.post("/open/v1/memory/sync/extract", payload) + data = response.get("data") or {} + status = data.get("status") + if status != "SUCCESS": + raise FixtureError(f"{fixture_name}: extract status was {status!r}, response={response}") + return data + + +def assert_categories( + client: HttpClient, + fixture_name: str, + request: dict[str, Any], + extract_data: dict[str, Any], + expected_categories: list[str], +) -> None: + if not expected_categories: + return + item_ids = extract_data.get("itemIds") or [] + if not item_ids: + raise FixtureError(f"{fixture_name}: extract returned no itemIds") + response = client.get( + "/admin/v1/items", + {"userId": request["userId"], "agentId": request["agentId"], "pageSize": "100"}, + ) + items = (response.get("data") or {}).get("items") or [] + categories = {item.get("category") for item in items if item.get("category")} + for category in expected_categories: + if category not in categories: + raise FixtureError( + f"{fixture_name}: missing category {category!r}; got {sorted(categories)}" + ) + + +def assert_retrieve( + client: HttpClient, + extract_request: dict[str, Any], + expectation: dict[str, Any], + fixture_name: str, +) -> None: + query = expectation.get("query") + if not query: + raise FixtureError(f"{fixture_name}: query expectation is missing query") + payload = { + "userId": extract_request["userId"], + "agentId": extract_request["agentId"], + "query": query, + "strategy": "SIMPLE", + } + response = client.post("/open/v1/memory/retrieve", payload) + data = response.get("data") or {} + items = data.get("items") or [] + haystack = json.dumps(items, ensure_ascii=False) + + for phrase in expectation.get("mustContain") or []: + if phrase not in haystack: + raise FixtureError( + f"{fixture_name}: query {query!r} did not return phrase {phrase!r}" + ) + + categories = {item.get("category") for item in items if item.get("category")} + for category in expectation.get("expectedCategories") or []: + if category not in categories: + raise FixtureError( + f"{fixture_name}: query {query!r} missing category {category!r}; " + f"got {sorted(categories)}" + ) + + +if __name__ == "__main__": + raise SystemExit(main()) From eeb31e1305f2cccb85db65d61f283f742104eaca Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 25 May 2026 22:19:04 +0800 Subject: [PATCH 21/54] feat: refine agent rawdata timeline identity --- ...2026-05-24-rawdata-agent-implementation.md | 2325 +++++++++++++++++ .../specs/2026-05-24-rawdata-agent-design.md | 1412 ++++++++++ memind-clients/java/README.md | 3 +- memind-clients/python/README.md | 3 +- memind-clients/python/tests/test_client.py | 1 + memind-clients/typescript/README.md | 3 +- .../typescript/src/types/message.ts | 3 +- .../typescript/tests/client.test.ts | 2 + memind-integrations/claude-code/README.md | 3 +- .../claude-code/scripts/ingest.py | 6 +- .../claude-code/scripts/lib/agent_timeline.py | 5 +- .../claude-code/scripts/lib/state.py | 8 +- .../claude-code/tests/test_agent_timeline.py | 4 + .../claude-code/tests/test_hooks.py | 19 +- .../claude-code/tests/test_state.py | 14 +- memind-integrations/codex/README.md | 3 +- memind-integrations/codex/scripts/ingest.py | 5 +- .../codex/scripts/lib/agent_timeline.py | 5 +- .../codex/scripts/lib/state.py | 8 +- .../codex/tests/test_agent_timeline.py | 4 + memind-integrations/codex/tests/test_hooks.py | 19 +- memind-integrations/codex/tests/test_state.py | 14 +- .../jdbc/internal/support/JsonCodecTest.java | 1 + .../agent/chunk/AgentEpisodeAssembler.java | 18 +- .../agent/chunk/AgentSegmentFormatter.java | 4 +- .../agent/chunk/AgentTimelineChunker.java | 1 + .../agent/content/AgentTimelineContent.java | 41 +- .../rawdata/agent/model/AgentEvent.java | 2 +- .../agent/privacy/AgentEventRedactor.java | 2 +- .../chunk/AgentEpisodeAssemblerTest.java | 2 +- .../agent/chunk/AgentEpisodeTestSupport.java | 1 + .../content/AgentTimelineContentTest.java | 49 +- ...gentExtractionPipelineIntegrationTest.java | 1 + .../AgentRawDataAutoConfigurationTest.java | 1 + .../AgentTimelineOpenApiIntegrationTest.java | 11 +- .../server/MemindServerApplicationTest.java | 3 + 36 files changed, 3934 insertions(+), 72 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-24-rawdata-agent-implementation.md create mode 100644 docs/superpowers/specs/2026-05-24-rawdata-agent-design.md diff --git a/docs/superpowers/plans/2026-05-24-rawdata-agent-implementation.md b/docs/superpowers/plans/2026-05-24-rawdata-agent-implementation.md new file mode 100644 index 00000000..999b0de6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-rawdata-agent-implementation.md @@ -0,0 +1,2325 @@ +# rawdata-agent Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a canonical `rawdata-agent` RawData plugin so Memind can ingest coding-agent timelines, derive episode evidence, extract AGENT memories, and feed existing Insight Tree, graph, and retrieval paths. + +**Architecture:** Implement `agent_timeline` as a new RawContent plugin, not a parallel observation database. The plugin produces deterministic `agent_episode` segments, AGENT-scoped `TOOL`, `RESOLUTION`, `PLAYBOOK`, and `DIRECTIVE` items, and optional current-core graph hints through `ExtractedMemoryEntry.graphHints()`. + +**Tech Stack:** Java 21, Maven, Reactor, Spring Boot auto-configuration, Jackson raw content subtype registration, Memind core RawData/MemoryItem/Insight/Graph APIs, Python/Java/TypeScript clients, Python Claude Code/Codex integrations. + +--- + +## Source Spec + +Implement against [2026-05-24-rawdata-agent-design.md](/Users/zhengyate/dev/openmemind/memind/docs/superpowers/specs/2026-05-24-rawdata-agent-design.md). If this plan and the spec conflict, pause and update the plan before coding. + +## Implementation Order + +1. Core support: `tools` insight type, migration-safe default reconciliation, shared graph hint converter, retrieval metadata. +2. New `memind-plugin-rawdata-agent` module: schema, redaction, episode assembly, processor, extractor. +3. Spring Boot starter and server registration. +4. Client convenience wrappers and typed response metadata. +5. Claude Code and Codex timeline capture and retrieval formatting. +6. Cross-store, integration, and evaluation verification. + +## File Structure + +### Core + +- Modify `memind-core/src/main/java/com/openmemind/ai/memory/core/data/DefaultInsightTypes.java` + - Add built-in AGENT `tools` branch insight type. +- Modify `memind-core/src/test/java/com/openmemind/ai/memory/core/data/DefaultInsightTypesTest.java` + - Assert `tools` exists, maps to `tool`, and is AGENT scoped. +- Create `memind-core/src/main/java/com/openmemind/ai/memory/core/store/insight/DefaultInsightTypeReconciler.java` + - Idempotently adds missing built-in insight types without deleting or overwriting user-defined types. +- Create `memind-core/src/test/java/com/openmemind/ai/memory/core/store/insight/DefaultInsightTypeReconcilerTest.java` + - Proves missing `tools` is inserted and customized existing insight types are preserved. +- Modify `memind-core/src/main/java/com/openmemind/ai/memory/core/store/InMemoryMemoryStore.java` + - Use the reconciler at startup. +- Modify each JDBC store constructor: + - `memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-sqlite/src/main/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStore.java` + - `memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-mysql/src/main/java/com/openmemind/ai/memory/plugin/jdbc/mysql/MysqlMemoryStore.java` + - `memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-postgresql/src/main/java/com/openmemind/ai/memory/plugin/jdbc/postgresql/PostgresqlMemoryStore.java` + - Replace seed-only behavior with migration-safe reconciliation. +- Add tests in each store test class proving existing stores receive `tools`: + - `memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-sqlite/src/test/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStoreTest.java` + - `memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-mysql/src/test/java/com/openmemind/ai/memory/plugin/jdbc/mysql/MysqlMemoryStoreTest.java` + - `memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-postgresql/src/test/java/com/openmemind/ai/memory/plugin/jdbc/postgresql/PostgresqlMemoryStoreTest.java` +- Create `memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedGraphHintConverter.java` + - Shared, public support converter from `MemoryItemExtractionResponse.ExtractedItem` entities/causal relations to `ExtractedGraphHints`. +- Modify `memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/strategy/LlmItemExtractionStrategy.java` + - Use `ExtractedGraphHintConverter` instead of private local conversion helpers. +- Add tests: + - `memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedGraphHintConverterTest.java` + - Update existing `LlmItemExtractionStrategy` tests if private helper expectations move. +- Modify `memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/ScoredResult.java` + - Add optional category and metadata for ITEM results while keeping existing constructors. +- Modify item retrieval paths that construct ITEM `ScoredResult`: + - `memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/tier/ItemTierRetriever.java` + - `memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/strategy/SimpleRetrievalStrategy.java` + - `memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/strategy/DeepRetrievalStrategy.java` + - `memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/temporal/DefaultTemporalItemChannel.java` + - `memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/graph/DefaultRetrievalGraphAssistant.java` + - `memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/graph/GraphExpansionEngine.java` + - Preserve metadata through rerank/merge copies. +- Modify server/client retrieval views to expose returned item category and metadata: + - `memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/response/RetrieveMemoryResponse.java` + - `memind-server/src/main/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryApplicationService.java` + - `memind-clients/python/src/memind/types/memory.py` + - `memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/response/RetrieveMemoryResponse.java` + - `memind-clients/typescript/src/types/memory.ts` + +### rawdata-agent Plugin + +- Create module: + - `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/pom.xml` +- Modify module lists: + - `memind-plugins/memind-plugin-rawdatas/pom.xml` + - Root/parent module list only if it explicitly enumerates rawdata child modules. +- Create package root `com.openmemind.ai.memory.plugin.rawdata.agent`. +- Create: + - `AgentRawContentTypeRegistrar.java` + - `content/AgentTimelineContent.java` + - `model/AgentTimeline.java` + - `model/AgentEvent.java` + - `model/AgentEventKind.java` + - `model/AgentEventStatus.java` + - `model/AgentProject.java` + - `model/AgentGitContext.java` + - `model/AgentEpisode.java` + - `model/AgentCommand.java` + - `model/AgentFileReference.java` + - `model/AgentToolCall.java` + - `model/AgentOutcome.java` + - `config/AgentChunkingOptions.java` + - `config/AgentExtractionOptions.java` + - `config/AgentPrivacyOptions.java` + - `config/AgentRawDataOptions.java` + - `privacy/SecretPatternRedactor.java` + - `privacy/AgentEventRedactor.java` + - `chunk/AgentEpisodeAssembler.java` + - `chunk/AgentSegmentFormatter.java` + - `chunk/AgentTimelineChunker.java` + - `caption/AgentCaptionGenerator.java` + - `processor/AgentTimelineContentProcessor.java` + - `item/AgentItemExtractionStrategy.java` + - `item/AgentItemPrompts.java` + - `item/AgentMemoryItemFactory.java` + - `plugin/AgentRawDataPlugin.java` +- Create tests under matching paths for each public behavior. + +### rawdata-agent Starter + +- Create: + - `memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/pom.xml` + - `src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/AgentRawDataAutoConfiguration.java` + - `src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/AgentRawDataProperties.java` + - `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` + - `src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/AgentRawDataAutoConfigurationTest.java` +- Modify: + - `memind-plugins/memind-plugin-spring-boot-starters/pom.xml` + - `memind-server/pom.xml` + - `memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerApplicationTest.java` + +### Clients + +- Python: + - `memind-clients/python/src/memind/types/message.py` + - `memind-clients/python/src/memind/types/memory.py` + - `memind-clients/python/src/memind/resources/memory.py` + - `memind-clients/python/src/memind/resources/async_memory.py` + - Tests in `memind-clients/python/tests/`. +- Java: + - Keep `MapRawContent` path documented. + - Optionally add typed classes under `memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/common/`. + - Tests in `memind-clients/java/memind-client/src/test/java/com/openmemind/ai/client/model/common/`. +- TypeScript: + - Add `AgentTimelineContent` type aliases in `memind-clients/typescript/src/types/message.ts`. + - Add serialization tests. + +### Claude Code and Codex Integrations + +- Shared concepts are implemented independently in each integration because current directories are standalone. +- Claude Code: + - `memind-integrations/claude-code/hooks/hooks.json` + - `memind-integrations/claude-code/scripts/pre_tool_use.py` + - `memind-integrations/claude-code/scripts/post_tool_use.py` + - `memind-integrations/claude-code/scripts/lib/agent_timeline.py` + - `memind-integrations/claude-code/scripts/lib/state.py` + - `memind-integrations/claude-code/scripts/lib/client.py` + - `memind-integrations/claude-code/scripts/ingest.py` + - `memind-integrations/claude-code/scripts/pre_compact.py` + - `memind-integrations/claude-code/scripts/session_end.py` + - `memind-integrations/claude-code/scripts/retrieve.py` + - Tests under `memind-integrations/claude-code/tests/`. +- Codex: + - `memind-integrations/codex/hooks/hooks.json` + - `memind-integrations/codex/scripts/pre_tool_use.py` + - `memind-integrations/codex/scripts/post_tool_use.py` + - `memind-integrations/codex/scripts/lib/agent_timeline.py` + - `memind-integrations/codex/scripts/lib/state.py` + - `memind-integrations/codex/scripts/lib/client.py` + - `memind-integrations/codex/scripts/ingest.py` + - `memind-integrations/codex/scripts/retrieve.py` + - Tests under `memind-integrations/codex/tests/`. + +--- + +## Tasks + +### Task 1: Add Built-In `tools` Insight Type + +**Files:** +- Modify: `memind-core/src/main/java/com/openmemind/ai/memory/core/data/DefaultInsightTypes.java` +- Modify: `memind-core/src/test/java/com/openmemind/ai/memory/core/data/DefaultInsightTypesTest.java` + +- [ ] **Step 1: Write failing tests for `tools`** + +Add assertions: + +```java +@Test +@DisplayName("all() should expose tools as an agent branch insight type") +void allShouldExposeToolsAgentBranchInsightType() { + assertThat(DefaultInsightTypes.all()) + .extracting(MemoryInsightType::name) + .contains("tools"); +} + +@Test +@DisplayName("tools should map to tool category and AGENT scope") +void toolsShouldMapToToolCategoryAndAgentScope() { + assertThat(DefaultInsightTypes.tools().categories()).containsExactly("tool"); + assertThat(DefaultInsightTypes.tools().scope()).isEqualTo(MemoryScope.AGENT); + assertThat(DefaultInsightTypes.tools().insightAnalysisMode()) + .isEqualTo(com.openmemind.ai.memory.core.data.enums.InsightAnalysisMode.BRANCH); +} +``` + +- [ ] **Step 2: Run the failing test** + +Run: + +```bash +mvn -pl memind-core -Dtest=DefaultInsightTypesTest test +``` + +Expected: compilation fails because `DefaultInsightTypes.tools()` does not exist. + +- [ ] **Step 3: Add `DefaultInsightTypes.tools()`** + +Add after `resolutions()`: + +```java +public static MemoryInsightType tools() { + return new MemoryInsightType( + 28L, + "tools", + "Tool and command usage patterns. Group by stable tool name, command family," + + " invocation pattern, validation command, or repeated failure mode.", + null, + List.of("tool"), + DEFAULT_TARGET_TOKENS, + null, + null, + null, + InsightAnalysisMode.BRANCH, + null, + MemoryScope.AGENT); +} +``` + +Update `all()` to include `tools()` between `resolutions()` and root types. + +- [ ] **Step 4: Run the test** + +Run: + +```bash +mvn -pl memind-core -Dtest=DefaultInsightTypesTest test +``` + +Expected: tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add memind-core/src/main/java/com/openmemind/ai/memory/core/data/DefaultInsightTypes.java \ + memind-core/src/test/java/com/openmemind/ai/memory/core/data/DefaultInsightTypesTest.java +git commit -m "feat(core): add tools agent insight type" +``` + +### Task 2: Reconcile Built-In Insight Types for Existing Stores + +**Files:** +- Create: `memind-core/src/main/java/com/openmemind/ai/memory/core/store/insight/DefaultInsightTypeReconciler.java` +- Create: `memind-core/src/test/java/com/openmemind/ai/memory/core/store/insight/DefaultInsightTypeReconcilerTest.java` +- Modify: `memind-core/src/main/java/com/openmemind/ai/memory/core/store/InMemoryMemoryStore.java` +- Modify: `memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-sqlite/src/main/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStore.java` +- Modify: `memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-mysql/src/main/java/com/openmemind/ai/memory/plugin/jdbc/mysql/MysqlMemoryStore.java` +- Modify: `memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-postgresql/src/main/java/com/openmemind/ai/memory/plugin/jdbc/postgresql/PostgresqlMemoryStore.java` +- Test: store-specific existing test classes. + +- [ ] **Step 1: Write reconciler tests** + +Create `DefaultInsightTypeReconcilerTest`: + +```java +class DefaultInsightTypeReconcilerTest { + + @Test + void insertsMissingBuiltInTypesOnly() { + var ops = new InMemoryInsightOperations(); + ops.upsertInsightTypes(List.of(DefaultInsightTypes.identity())); + + DefaultInsightTypeReconciler.reconcile(ops); + + assertThat(ops.getInsightType("identity")).isPresent(); + assertThat(ops.getInsightType("tools")).isPresent(); + } + + @Test + void preservesExistingCustomizedType() { + var ops = new InMemoryInsightOperations(); + var customized = + DefaultInsightTypes.tools().withTargetTokens(1234); + ops.upsertInsightTypes(List.of(customized)); + + DefaultInsightTypeReconciler.reconcile(ops); + + assertThat(ops.getInsightType("tools").orElseThrow().targetTokens()) + .isEqualTo(1234); + } +} +``` + +Import: + +```java +import static org.assertj.core.api.Assertions.assertThat; + +import com.openmemind.ai.memory.core.data.DefaultInsightTypes; +import org.junit.jupiter.api.Test; +``` + +- [ ] **Step 2: Run the failing reconciler test** + +Run: + +```bash +mvn -pl memind-core -Dtest=DefaultInsightTypeReconcilerTest test +``` + +Expected: compilation fails because `DefaultInsightTypeReconciler` does not exist. + +- [ ] **Step 3: Implement reconciler** + +Create: + +```java +package com.openmemind.ai.memory.core.store.insight; + +import com.openmemind.ai.memory.core.data.DefaultInsightTypes; +import com.openmemind.ai.memory.core.data.MemoryInsightType; +import java.util.List; + +public final class DefaultInsightTypeReconciler { + + private DefaultInsightTypeReconciler() {} + + public static void reconcile(InsightOperations operations) { + if (operations == null) { + return; + } + List missing = + DefaultInsightTypes.all().stream() + .filter(type -> operations.getInsightType(type.name()).isEmpty()) + .toList(); + if (!missing.isEmpty()) { + operations.upsertInsightTypes(missing); + } + } +} +``` + +- [ ] **Step 4: Use reconciler in stores** + +Change each store constructor from: + +```java +if (initResult.createdInsightTypeTable()) { + upsertInsightTypes(DefaultInsightTypes.all()); +} +``` + +to: + +```java +DefaultInsightTypeReconciler.reconcile(this); +``` + +For `InMemoryMemoryStore`, replace direct `DefaultInsightTypes.all()` seeding with: + +```java +DefaultInsightTypeReconciler.reconcile(insightOperations); +``` + +Add imports where needed. + +- [ ] **Step 5: Add store tests** + +In each JDBC store test, add a test equivalent to: + +```java +@Test +void constructorReconcilesMissingBuiltInInsightTypesForExistingStore() { + var store = newStore(); + assertThat(store.getInsightType("tools")).isPresent(); + + removeInsightTypeForUpgradeSimulation("tools"); + + var reopened = reopenStoreWithSameDatabase(); + + assertThat(reopened.getInsightType("tools")).isPresent(); +} +``` + +Also add a preservation test: + +```java +@Test +void constructorDoesNotOverwriteExistingCustomizedBuiltInInsightType() { + var store = newStore(); + store.upsertInsightTypes( + List.of(DefaultInsightTypes.tools().withTargetTokens(1234))); + + var reopened = reopenStoreWithSameDatabase(); + + assertThat(reopened.getInsightType("tools")).isPresent(); + assertThat(reopened.getInsightType("tools").orElseThrow().targetTokens()) + .isEqualTo(1234); +} +``` + +Use the helper methods already present in each store test class. If a class does not have `reopenStoreWithSameDatabase()`, use the same datasource instance to construct a second store. + +For SQLite, implement the upgrade simulation with the test datasource: + +```java +new NamedParameterJdbcTemplate(dataSource) + .getJdbcOperations() + .update("DELETE FROM memory_insight_type WHERE name = ?", "tools"); +``` + +For MySQL/PostgreSQL, use the same database helper style already used in the test class, but keep the SQL equivalent: + +```sql +DELETE FROM memory_insight_type WHERE name = 'tools' +``` + +- [ ] **Step 6: Run tests** + +Run: + +```bash +mvn -pl memind-core -Dtest=DefaultInsightTypeReconcilerTest test +mvn -pl memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-sqlite -Dtest=SqliteMemoryStoreTest test +mvn -pl memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-mysql -Dtest=MysqlMemoryStoreTest test +mvn -pl memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-postgresql -Dtest=PostgresqlMemoryStoreTest test +``` + +Expected: all selected tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add memind-core/src/main/java/com/openmemind/ai/memory/core/store/insight/DefaultInsightTypeReconciler.java \ + memind-core/src/test/java/com/openmemind/ai/memory/core/store/insight/DefaultInsightTypeReconcilerTest.java \ + memind-core/src/main/java/com/openmemind/ai/memory/core/store/InMemoryMemoryStore.java \ + memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-sqlite/src/main/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStore.java \ + memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-mysql/src/main/java/com/openmemind/ai/memory/plugin/jdbc/mysql/MysqlMemoryStore.java \ + memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-postgresql/src/main/java/com/openmemind/ai/memory/plugin/jdbc/postgresql/PostgresqlMemoryStore.java \ + memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-sqlite/src/test/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStoreTest.java \ + memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-mysql/src/test/java/com/openmemind/ai/memory/plugin/jdbc/mysql/MysqlMemoryStoreTest.java \ + memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-postgresql/src/test/java/com/openmemind/ai/memory/plugin/jdbc/postgresql/PostgresqlMemoryStoreTest.java +git commit -m "fix(core): reconcile default insight types on startup" +``` + +### Task 3: Extract Shared Graph Hint Conversion + +**Files:** +- Create: `memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedGraphHintConverter.java` +- Create: `memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedGraphHintConverterTest.java` +- Modify: `memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/strategy/LlmItemExtractionStrategy.java` + +- [ ] **Step 1: Write converter tests** + +Create: + +```java +class ExtractedGraphHintConverterTest { + + @Test + void convertsEntitiesAndCausalRelations() { + var item = + new MemoryItemExtractionResponse.ExtractedItem( + "content", + 0.9f, + null, + null, + List.of("resolutions"), + Map.of(), + "resolution", + List.of( + new MemoryItemExtractionResponse.ExtractedEntity( + "src/payment/calc.ts", "object", 1.5f)), + List.of( + new MemoryItemExtractionResponse.ExtractedCausalRelation( + 0, 1, "enabled_by", -1.0f))); + + ExtractedGraphHints hints = ExtractedGraphHintConverter.from(item); + + assertThat(hints.entities()).hasSize(1); + assertThat(hints.entities().getFirst().name()).isEqualTo("src/payment/calc.ts"); + assertThat(hints.entities().getFirst().salience()).isEqualTo(1.0f); + assertThat(hints.causalRelations()).hasSize(1); + assertThat(hints.causalRelations().getFirst().relationType()).isEqualTo("enabled_by"); + assertThat(hints.causalRelations().getFirst().strength()).isEqualTo(0.0f); + } + + @Test + void dropsBlankEntitiesAndIncompleteCausalRelations() { + var item = + new MemoryItemExtractionResponse.ExtractedItem( + "content", + 0.9f, + null, + null, + List.of(), + Map.of(), + "tool", + List.of(new MemoryItemExtractionResponse.ExtractedEntity(" ", "object", 0.5f)), + List.of(new MemoryItemExtractionResponse.ExtractedCausalRelation(null, 1, "enabled_by", 0.5f))); + + ExtractedGraphHints hints = ExtractedGraphHintConverter.from(item); + + assertThat(hints.entities()).isEmpty(); + assertThat(hints.causalRelations()).isEmpty(); + } +} +``` + +Imports: + +```java +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +``` + +- [ ] **Step 2: Run failing test** + +Run: + +```bash +mvn -pl memind-core -Dtest=ExtractedGraphHintConverterTest test +``` + +Expected: compilation fails because converter does not exist. + +- [ ] **Step 3: Implement converter** + +Move the conversion logic from `LlmItemExtractionStrategy` into a public final support class. The class must expose: + +```java +public static ExtractedGraphHints from(MemoryItemExtractionResponse.ExtractedItem item) +``` + +and: + +```java +public static List toEntityHints( + List entities) +public static List toCausalHints( + List causalRelations) +``` + +Keep clamp behavior: null stays null, values clamp to `[0.0, 1.0]`. Preserve alias observation conversion through `EntityAliasClass.fromWireValue(observation.aliasClass())`. + +- [ ] **Step 4: Update `LlmItemExtractionStrategy`** + +Replace: + +```java +new ExtractedGraphHints(toEntityHints(item.entities()), toCausalHints(item.causalRelations())) +``` + +with: + +```java +ExtractedGraphHintConverter.from(item) +``` + +Remove now-unused private conversion helpers from `LlmItemExtractionStrategy`. + +- [ ] **Step 5: Run tests** + +Run: + +```bash +mvn -pl memind-core -Dtest=ExtractedGraphHintConverterTest,LlmItemExtractionStrategyTest test +``` + +Expected: tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedGraphHintConverter.java \ + memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedGraphHintConverterTest.java \ + memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/strategy/LlmItemExtractionStrategy.java +git commit -m "refactor(core): share graph hint conversion" +``` + +### Task 4: Expose Item Category and Metadata in Retrieval Responses + +**Files:** +- Modify: `memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/ScoredResult.java` +- Modify: item retriever and merge/rerank paths that copy `ScoredResult` +- Modify: `memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/response/RetrieveMemoryResponse.java` +- Modify: `memind-server/src/main/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryApplicationService.java` +- Modify: client response types in Python, Java, TypeScript. +- Tests: existing retrieval/server/client tests. + +- [ ] **Step 1: Write failing server response test** + +Add or update a server service test so a retrieved item with category `TOOL` and metadata `{"toolName":"Bash"}` serializes as: + +```json +{ + "id": "1", + "text": "Use npm test payment", + "category": "tool", + "metadata": {"toolName": "Bash"} +} +``` + +Use existing `OpenMemoryApplicationService` tests if present. Otherwise add assertions to the nearest retrieve response serialization test. + +- [ ] **Step 2: Extend `ScoredResult`** + +Change record to: + +```java +public record ScoredResult( + SourceType sourceType, + String sourceId, + String text, + float vectorScore, + double finalScore, + Instant occurredAt, + String category, + Map metadata) { +``` + +Add compatible constructors matching current signatures and defaulting category to `null`, metadata to `Map.of()`. + +- [ ] **Step 3: Populate item metadata** + +Where `ScoredResult` is constructed from `MemoryItem`, pass: + +```java +item.category() == null ? null : item.category().categoryName() +item.metadata() +``` + +Where `ScoredResult` is copied by rerank, scoring, graph expansion, or time decay, preserve `category()` and `metadata()`. + +Update every constructor/copy site discovered by: + +```bash +rg -n "new ScoredResult|withOccurredAt|ScoredResult\\(" memind-core/src/main/java memind-server/src/main/java -g'*.java' +``` + +At minimum, cover these existing classes: + +```text +memind-core/src/main/java/com/openmemind/ai/memory/core/llm/rerank/LlmReranker.java +memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/ResultMerger.java +memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/RawDataAggregator.java +memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/TimeDecay.java +memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/thread/ThreadAssistMemberRanker.java +memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/tier/ItemTierRetriever.java +memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/temporal/DefaultTemporalItemChannel.java +memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/graph/DefaultRetrievalGraphAssistant.java +memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/graph/GraphExpansionEngine.java +memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/strategy/SimpleRetrievalStrategy.java +memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/strategy/DeepRetrievalStrategy.java +``` + +Any constructor from INSIGHT or RAW_DATA can keep `category = null` and `metadata = Map.of()`. Any copy of an ITEM result must preserve the original category and metadata. + +- [ ] **Step 4: Extend server response** + +Change: + +```java +public record RetrievedItemView( + String id, String text, float vectorScore, double finalScore, Instant occurredAt) {} +``` + +to include: + +```java +String category, +Map metadata +``` + +Update `toRetrievedItemView(...)` to pass `item.category()` and `item.metadata()`. + +- [ ] **Step 5: Extend clients** + +Python `RetrievedItem`: + +```python +category: str | None = None +metadata: dict[str, Any] = Field(default_factory=dict) +``` + +Java `RetrievedItem`: + +```java +String category, Map metadata +``` + +TypeScript `RetrievedItem`: + +```ts +category?: string +metadata?: Record +``` + +- [ ] **Step 6: Run tests** + +Run: + +```bash +mvn -pl memind-core -Dtest='*Retrieval*Test,*Reranker*Test' test +mvn -pl memind-server test +UV_CACHE_DIR=.uv-cache uv run --python /opt/homebrew/bin/python3.12 --extra dev pytest memind-clients/python/tests -q +mvn -pl memind-clients/java/memind-client test +PATH=/Users/zhengyate/.nvm/versions/node/v22.22.0/bin:$PATH COREPACK_HOME=/tmp/memind-corepack pnpm --dir memind-clients/typescript test +``` + +Expected: all selected tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add memind-core memind-server memind-clients/python memind-clients/java/memind-client memind-clients/typescript +git commit -m "feat(retrieval): expose item category metadata" +``` + +### Task 5: Scaffold `memind-plugin-rawdata-agent` + +**Files:** +- Create: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/pom.xml` +- Modify: `memind-plugins/memind-plugin-rawdatas/pom.xml` +- Create registrar/content/model/config classes. +- Tests under `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/` + +- [ ] **Step 1: Add module POM** + +Use `memind-plugin-rawdata-toolcall/pom.xml` as the template. Artifact: + +```xml +memind-plugin-rawdata-agent +Memind - Agent RawData Plugin +``` + +Dependencies: `memind-core`, JUnit Jupiter, AssertJ, Mockito, Reactor Test. + +- [ ] **Step 2: Add module to parent** + +Add: + +```xml +memind-plugin-rawdata-agent +``` + +to `memind-plugins/memind-plugin-rawdatas/pom.xml`. + +- [ ] **Step 3: Write failing registrar/content tests** + +Create `AgentTimelineContentTest` verifying: + +```java +AgentTimelineContent content = new AgentTimelineContent( + "claude-code", + "1.0", + "session-123", + "timeline-123", + project, + events); + +assertThat(content.contentType()).isEqualTo("AGENT_TIMELINE"); +assertThat(content.toContentString()).contains("Goal:", "npm test payment"); +AgentTimelineContent duplicate = new AgentTimelineContent( + "claude-code", + "1.0", + "session-123", + "timeline-123", + project, + events); +assertThat(content.getContentId()).isEqualTo(duplicate.getContentId()); +``` + +Create `AgentRawContentTypeRegistrarTest` verifying subtype: + +```java +assertThat(new AgentRawContentTypeRegistrar().subtypes()) + .containsEntry("agent_timeline", AgentTimelineContent.class); +``` + +- [ ] **Step 4: Implement model records/classes** + +Use immutable records where possible: + +```java +public record AgentEvent( + String id, + Integer seq, + AgentEventKind kind, + Instant occurredAt, + String text, + String toolName, + String input, + String output, + AgentEventStatus status, + Long durationMs, + String path, + String operation, + String command, + Integer exitCode, + Map metadata) {} +``` + +`text` is required for `user_prompt`, `assistant_message`, `error`, and summary-like events. It must not be hidden inside `metadata`; the episode assembler uses `user_prompt.text` as the primary goal signal. + +`AgentEventKind` enum values must map lower snake JSON values: + +```java +USER_PROMPT, ASSISTANT_MESSAGE, TOOL_CALL, TOOL_RESULT, COMMAND, FILE_READ, +FILE_EDIT, TEST_RESULT, PERMISSION_REQUEST, ERROR, STOP, SESSION_END, TASK_COMPLETED +``` + +Use Jackson annotations or string parsing consistent with existing project style. + +- [ ] **Step 5: Implement `AgentTimelineContent`** + +Requirements: + +- `TYPE = "AGENT_TIMELINE"`. +- JSON raw subtype remains `"agent_timeline"` through registrar. +- `getContentId()` hashes canonical identity: + +```text +sourceClient | sessionId | timelineId | ordered event IDs | normalized event content hash +``` + +- `toContentString()` returns deterministic compact text. +- `user_prompt.text` survives Jackson round-trip and appears in `toContentString()` as the episode goal source. +- Missing optional fields are tolerated. +- Events are sorted by `seq`, then `occurredAt`, then `id`. + +- [ ] **Step 6: Run module tests** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent test +``` + +Expected: tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add memind-plugins/memind-plugin-rawdatas/pom.xml \ + memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent +git commit -m "feat(rawdata-agent): scaffold agent timeline content" +``` + +### Task 6: Implement Privacy Redaction + +**Files:** +- Create: `config/AgentPrivacyOptions.java` +- Create: `privacy/SecretPatternRedactor.java` +- Create: `privacy/AgentEventRedactor.java` +- Tests: `SecretPatternRedactorTest.java`, `AgentEventRedactorTest.java` + +- [ ] **Step 1: Write redaction tests** + +Test cases: + +```java +assertThat(redactor.redact("Authorization: Bearer abc.def.ghi").text()) + .contains("[REDACTED:bearer_token]"); +assertThat(redactor.redact("DATABASE_URL=postgres://u:p@example/db").text()) + .contains("[REDACTED:database_url]"); +assertThat(redactor.redact("-----BEGIN PRIVATE KEY-----\nabc").text()) + .contains("[REDACTED:private_key]"); +``` + +For event redaction: + +```java +AgentEvent event = commandWithOutput("npm test", "ok ".repeat(5000)); +AgentEvent redacted = eventRedactor.redact(event); +assertThat(redacted.output()).hasSizeLessThanOrEqualTo(4000); +assertThat(redacted.metadata()).containsEntry("redacted", true); +``` + +- [ ] **Step 2: Run failing tests** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=SecretPatternRedactorTest,AgentEventRedactorTest test +``` + +Expected: compilation fails. + +- [ ] **Step 3: Implement privacy options** + +Defaults: + +```java +redactSecrets = true +maxInputChars = 2000 +maxOutputChars = 4000 +captureFileContent = false +denyPathPatterns = List.of(".env", "*.pem", "*.key") +allowPathPatterns = List.of() +``` + +- [ ] **Step 4: Implement redactors** + +Rules: + +- Redact bearer/API tokens, common secret env vars, database URLs with credentials, private key blocks, cloud credentials. +- Truncate `input` and `output`. +- Drop file contents by default. +- Add metadata: + +```java +"redacted": true +"redactionKinds": List.of("bearer_token") +``` + +- [ ] **Step 5: Run tests** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=SecretPatternRedactorTest,AgentEventRedactorTest test +``` + +Expected: tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java \ + memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java +git commit -m "feat(rawdata-agent): redact sensitive agent events" +``` + +### Task 7: Implement Episode Assembly and Segment Formatting + +**Files:** +- Create: `chunk/AgentEpisodeAssembler.java` +- Create: `chunk/AgentSegmentFormatter.java` +- Create: `chunk/AgentTimelineChunker.java` +- Create: `caption/AgentCaptionGenerator.java` +- Tests: chunk/formatter/caption tests. + +- [ ] **Step 1: Write episode boundary tests** + +Use events: + +```text +e1 user_prompt +e2 command failed +e3 file_edit +e4 command success +e5 stop +``` + +Assert one episode with: + +```java +episode.goal() == "Fix payment tests" +episode.outcome() == AgentOutcome.SUCCESS +episode.eventIds() == ["e1","e2","e3","e4","e5"] +episode.files() contains "src/payment/calc.ts" +episode.commands() contains "npm test payment" +episode.failureSignals() contains "rounding mismatch" +``` + +Add secondary boundary tests: + +- new `user_prompt` closes previous episode. +- 31 minute gap splits episodes. +- event count over max splits. +- oversized episode splits into `investigation`, `implementation`, `validation`, `handoff`. + +- [ ] **Step 2: Write formatter test** + +Assert formatted text contains: + +```text +Goal: Fix payment tests. +Outcome: success +Files: src/payment/calc.ts +Commands: +- npm test payment -> failed: rounding mismatch +- npm test payment -> success +Evidence: +- e2: +- e4: +``` + +Assert metadata contains: + +```java +segmentType = "agent_episode" +episodeId +phase +sourceClient +sessionId +timelineId +files +commands +toolNames +failureSignals +eventIds +``` + +- [ ] **Step 3: Run failing tests** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentEpisodeAssemblerTest,AgentSegmentFormatterTest,AgentTimelineChunkerTest test +``` + +Expected: compilation fails. + +- [ ] **Step 4: Implement assembler** + +Rules: + +- Sort stable by `seq`, `occurredAt`, `id`. +- Primary start: `user_prompt`. +- Primary end: `stop`, `session_end`, `task_completed`. +- New `user_prompt` closes previous open episode. +- Secondary boundaries: `maxEventGap`, `maxEventsPerEpisode`, target token estimate, `taskId/subtaskId` change. +- Episode ID: + +```java +HashUtils.sampledSha256(sourceClient + "|" + sessionId + "|" + firstEventId + "|" + lastEventId + "|" + eventIds) +``` + +- [ ] **Step 5: Implement formatter/chunker/caption** + +`AgentTimelineChunker` must: + +- Redact before segment creation. +- Assemble episodes. +- Format deterministic segment content. +- Attach `SegmentRuntimeContext(start, end, null, sourceClient)`. + +`AgentCaptionGenerator` should return a deterministic short caption: + +```text +Agent episode: -> () +``` + +- [ ] **Step 6: Run tests** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentEpisodeAssemblerTest,AgentSegmentFormatterTest,AgentTimelineChunkerTest,AgentCaptionGeneratorTest test +``` + +Expected: tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java \ + memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java +git commit -m "feat(rawdata-agent): assemble agent episodes" +``` + +### Task 8: Implement Processor and Plugin Registration + +**Files:** +- Create: `processor/AgentTimelineContentProcessor.java` +- Create: `plugin/AgentRawDataPlugin.java` +- Tests: `AgentTimelineContentProcessorTest.java`, `AgentRawDataPluginTest.java` + +- [ ] **Step 1: Write processor tests** + +Assert: + +```java +assertThat(processor.contentClass()).isEqualTo(AgentTimelineContent.class); +assertThat(processor.contentType()).isEqualTo(AgentTimelineContent.TYPE); +assertThat(processor.allowedCategories()).containsExactlyInAnyOrderElementsOf(MemoryCategory.agentCategories()); +assertThat(processor.usesSourceIdentity()).isTrue(); +assertThat(processor.supportsInsight()).isTrue(); +assertThat(processor.itemExtractionStrategy()).isInstanceOf(AgentItemExtractionStrategy.class); +``` + +- [ ] **Step 2: Write plugin tests** + +Assert: + +```java +RawDataPlugin plugin = new AgentRawDataPlugin(); +assertThat(plugin.pluginId()).isEqualTo("rawdata-agent"); +assertThat(plugin.typeRegistrars()).extracting(RawContentTypeRegistrar::subtypes) + .anySatisfy(map -> assertThat(map).containsKey("agent_timeline")); +assertThat(plugin.processors(pluginContext())).hasSize(1); +``` + +- [ ] **Step 3: Run failing tests** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentTimelineContentProcessorTest,AgentRawDataPluginTest test +``` + +Expected: compilation fails. + +- [ ] **Step 4: Implement processor/plugin** + +`AgentTimelineContentProcessor` must return: + +```java +contentClass() -> AgentTimelineContent.class +contentType() -> AgentTimelineContent.TYPE +allowedCategories() -> MemoryCategory.agentCategories() +usesSourceIdentity() -> true +supportsInsight() -> true +``` + +Use: + +```java +new AgentTimelineChunker(options.chunking(), options.privacy()) +new AgentCaptionGenerator() +new AgentItemExtractionStrategy(context.chatClientRegistry().defaultClient(), context.promptRegistry(), options.extraction()) +``` + +- [ ] **Step 5: Run tests** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent test +``` + +Expected: tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent +git commit -m "feat(rawdata-agent): register agent rawdata processor" +``` + +### Task 9: Implement Deterministic Agent Item Extraction + +**Files:** +- Create: `item/AgentMemoryItemFactory.java` +- Create/modify: `item/AgentItemExtractionStrategy.java` +- Tests: deterministic extractor tests. + +- [ ] **Step 1: Write deterministic extraction tests** + +For a successful episode with command failure then pass: + +```java +List entries = strategy.extract(List.of(segment), DefaultInsightTypes.all(), config).block(); + +assertThat(entries).anySatisfy(entry -> { + assertThat(entry.category()).isEqualTo("tool"); + assertThat(entry.insightTypes()).containsExactly("tools"); + assertThat(entry.metadata()).containsEntry("episodeId", "episode-123"); +}); +assertThat(entries).anySatisfy(entry -> { + assertThat(entry.category()).isEqualTo("resolution"); + assertThat(entry.insightTypes()).containsExactly("resolutions"); + assertThat(entry.metadata()).containsKey("evidenceEventIds"); +}); +``` + +For a failed unresolved episode: + +```java +assertThat(entries).noneMatch(e -> "playbook".equals(e.category())); +assertThat(entries).noneMatch(e -> "resolution".equals(e.category())); +``` + +For exact duplicate extraction: + +```java +assertThat(first.get(0).content()).isEqualTo(second.get(0).content()); +``` + +- [ ] **Step 2: Run failing tests** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentItemExtractionStrategyTest test +``` + +Expected: compilation fails or assertions fail. + +- [ ] **Step 3: Implement deterministic TOOL** + +Emit TOOL when segment metadata has `toolNames` or `commands`. + +Canonical content examples: + +```text +Use npm test payment to validate changes touching src/payment/calc.ts. +Bash command npm test payment failed once and passed once in episode episode-123. +``` + +Metadata: + +```java +toolName, command, files, commands, toolNames, successCount, failCount, +episodeId, sessionId, timelineId, sourceClient, evidenceEventIds +``` + +- [ ] **Step 4: Implement conservative deterministic RESOLUTION** + +Emit RESOLUTION only when: + +- `failureSignals` is non-empty. +- `outcome` is `success` or `partial_success`. +- There is a later successful validation command or test result. +- There is an edit, conclusion, or successful command tying the fix/conclusion to the outcome. + +Use this deterministic validation rule: + +```java +failedSignalEvent.seq < validationEvent.seq + && validationEvent.status == AgentEventStatus.SUCCESS + && (validationEvent.kind == AgentEventKind.COMMAND + || validationEvent.kind == AgentEventKind.TEST_RESULT) + && validationEvent.command matches a failed command family or known validation command +``` + +Command family matching should normalize whitespace and strip volatile arguments before comparing. For example, `npm test payment`, `npm test -- payment`, and `pnpm test payment -- --runInBand` can be grouped by the stable test target `payment`; unrelated successful commands such as `git status` must not validate a failed test. + +Canonical content: + +```text + was resolved in and validated with . +``` + +- [ ] **Step 5: Implement graph hints for deterministic items** + +Use `ExtractedGraphHints` directly: + +- file path -> `object` +- command -> `object` +- failure signal -> `concept` +- tool -> `object` + +Do not emit custom coding relation names. + +- [ ] **Step 6: Run tests** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentItemExtractionStrategyTest test +``` + +Expected: tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java \ + memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java +git commit -m "feat(rawdata-agent): extract deterministic agent memories" +``` + +### Task 10: Add LLM Agent Extraction for Playbooks and Directives + +**Files:** +- Create/modify: `item/AgentItemPrompts.java` +- Modify: `item/AgentItemExtractionStrategy.java` +- Tests with mocked `StructuredChatClient`. + +- [ ] **Step 1: Write mocked LLM tests** + +Mock `structuredChatClient.call(messages, MemoryItemExtractionResponse.class)` to return: + +```java +new MemoryItemExtractionResponse( + List.of( + new ExtractedItem( + "When payment tests fail with rounding mismatch, inspect policy, edit calc.ts, then run npm test payment.", + 0.86f, + null, + List.of("playbooks"), + Map.of( + "trigger", "payment tests fail with rounding mismatch", + "steps", List.of("Inspect policy", "Edit calc.ts", "Run npm test payment"), + "expectedOutcome", "payment tests pass", + "evidenceEventIds", List.of("e3", "e4", "e5")), + "playbook"))) +``` + +Assert output keeps category `playbook`, insight type `playbooks`, deterministic metadata, and evidence IDs. + +Add negative tests: + +- playbook with one step is dropped. +- resolution without fix is dropped. +- item with category `profile` is dropped. +- evidence ID not present in segment metadata is dropped. + +- [ ] **Step 2: Run failing tests** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentItemExtractionStrategyLlmTest test +``` + +Expected: tests fail. + +- [ ] **Step 3: Implement prompt builder** + +Prompt must instruct: + +- Categories limited to `tool`, `resolution`, `playbook`, `directive`. +- Every item must include `metadata.evidenceEventIds`. +- Playbooks require trigger, at least two steps, expected outcome. +- Resolutions require problem and fix/conclusion. +- Use current graph entity vocabulary only. +- Use causal relations only with `caused_by`, `enabled_by`, `motivated_by`. + +- [ ] **Step 4: Implement LLM merge/gating** + +Flow: + +1. Build deterministic baseline. +2. Call LLM only when extraction options enable it and segment meets threshold. +3. Convert `MemoryItemExtractionResponse.ExtractedItem` to `ExtractedMemoryEntry`. +4. Use `ExtractedGraphHintConverter.from(item)`. +5. Merge deterministic metadata into every LLM item. +6. Drop invalid category/insight/evidence items. +7. Produce deterministic canonical content for TOOL/RESOLUTION when possible. + +- [ ] **Step 5: Run tests** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentItemExtractionStrategyTest,AgentItemExtractionStrategyLlmTest test +``` + +Expected: tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java \ + memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java +git commit -m "feat(rawdata-agent): extract playbooks and directives" +``` + +### Task 11: Add rawdata-agent Pipeline Integration Tests + +**Files:** +- Create: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/integration/AgentExtractionPipelineIntegrationTest.java` + +- [ ] **Step 1: Write integration tests** + +Use `Memory.builder().rawDataPlugin(new AgentRawDataPlugin(AgentRawDataOptions.defaults()))` with a fake/no-op vector and mocked LLM where needed. + +Test cases: + +- Successful timeline produces TOOL item. +- Failure + edit + successful validation produces RESOLUTION item. +- Complex successful episode can produce PLAYBOOK. +- Failed unresolved episode does not produce PLAYBOOK. +- Items are AGENT categories only. +- Exact duplicate complete timeline window does not duplicate durable items. +- TOOL item metadata includes `insightTypes=["tools"]`. +- RawData metadata includes `segmentType=agent_episode`. +- RawData segment text does not include unredacted secret. + +- [ ] **Step 2: Run failing integration tests** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentExtractionPipelineIntegrationTest test +``` + +Expected: failures reveal missing wiring. + +- [ ] **Step 3: Fix pipeline wiring** + +Address: + +- Processor registered in plugin. +- `allowedCategories()` applied. +- `supportsInsight()` true. +- `tools` insight type available. +- Duplicate complete-window behavior stable. +- Redaction happens before `Segment` persistence. + +- [ ] **Step 4: Run module tests** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent test +``` + +Expected: all plugin tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java \ + memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java +git commit -m "test(rawdata-agent): cover extraction pipeline" +``` + +### Task 12: Add Spring Boot Starter and Server Registration + +**Files:** +- Create starter module and autoconfiguration files. +- Modify: `memind-plugins/memind-plugin-spring-boot-starters/pom.xml` +- Modify: `memind-server/pom.xml` +- Modify: `memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerApplicationTest.java` + +- [ ] **Step 1: Write starter test** + +`AgentRawDataAutoConfigurationTest` should assert: + +```java +assertThat(context).hasSingleBean(RawDataPlugin.class); +assertThat(context.getBean("agentRawDataPlugin")).isInstanceOf(AgentRawDataPlugin.class); +mapper.readValue("{\"type\":\"agent_timeline\",\"sourceClient\":\"claude-code\",\"sessionId\":\"s\",\"timelineId\":\"t\",\"events\":[]}", RawContent.class) + .isInstanceOf(AgentTimelineContent.class); +``` + +Add disabled property test: + +```java +.withPropertyValues("memind.rawdata.agent.enabled=false") +``` + +and assert no plugin and unsupported raw content type. + +- [ ] **Step 2: Run failing starter test** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter test +``` + +Expected: module does not exist. + +- [ ] **Step 3: Implement starter** + +Properties prefix: + +```text +memind.rawdata.agent +``` + +Expose: + +- `enabled` +- `chunking.target-episode-tokens` +- `chunking.hard-max-tokens` +- `chunking.max-events-per-episode` +- `chunking.max-event-gap` +- `extraction.extract-tool` +- `extraction.extract-resolution` +- `extraction.extract-playbook` +- `extraction.extract-directive` +- `extraction.extract-on-every-tool` +- `extraction.min-events-for-extraction` +- `extraction.min-events-for-playbook` +- `extraction.require-success-for-playbook` +- `privacy.redact-secrets` +- `privacy.max-input-chars` +- `privacy.max-output-chars` +- `privacy.capture-file-content` +- `privacy.deny-path-patterns` + +Autoconfiguration bean: + +```java +@Bean("agentRawDataPlugin") +@ConditionalOnMissingBean(name = "agentRawDataPlugin") +RawDataPlugin agentRawDataPlugin(AgentRawDataProperties properties) { + return new AgentRawDataPlugin(properties.toOptions()); +} +``` + +- [ ] **Step 4: Register server dependency** + +Add to `memind-server/pom.xml`: + +```xml + + com.openmemind.ai + memind-plugin-rawdata-agent-starter + ${revision} + +``` + +Update `MemindServerApplicationTest` to assert `agentRawDataPlugin` exists and `/extract` ObjectMapper accepts `agent_timeline`. + +- [ ] **Step 5: Run tests** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter test +mvn -pl memind-server -Dtest=MemindServerApplicationTest test +``` + +Expected: tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add memind-plugins/memind-plugin-spring-boot-starters/pom.xml \ + memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter \ + memind-server/pom.xml \ + memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerApplicationTest.java +git commit -m "feat(rawdata-agent): add spring boot starter" +``` + +### Task 13: Add JDBC JSON Codec Coverage + +**Files:** +- Modify: `memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-core/pom.xml` +- Modify: `memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-core/src/test/java/com/openmemind/ai/memory/plugin/jdbc/internal/support/JsonCodecTest.java` + +- [ ] **Step 1: Add dependency** + +Add test/runtime dependency matching `toolcall`: + +```xml + + com.openmemind.ai + memind-plugin-rawdata-agent + ${revision} + test + +``` + +- [ ] **Step 2: Add codec test** + +Test: + +```java +void codecRoundTripsAgentTimelineWhenPluginSubtypeIsExplicitlyRegistered() { + var mapper = JsonCodec.createDefaultObjectMapper(); + RawContentJackson.registerAll(mapper, List.of(new AgentRawContentTypeRegistrar())); + var content = sampleAgentTimelineContent(); + + String json = mapper.writeValueAsString(content); + RawContent restored = mapper.readValue(json, RawContent.class); + + assertThat(restored).isInstanceOf(AgentTimelineContent.class); +} +``` + +- [ ] **Step 3: Run test** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-core -Dtest=JsonCodecTest test +``` + +Expected: passes. + +- [ ] **Step 4: Commit** + +```bash +git add memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-core/pom.xml \ + memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-core/src/test/java/com/openmemind/ai/memory/plugin/jdbc/internal/support/JsonCodecTest.java +git commit -m "test(jdbc): cover agent timeline json codec" +``` + +### Task 14: Add Client Convenience APIs + +**Files:** +- Python client resource/types/tests. +- Java client typed model or documented `MapRawContent` tests. +- TypeScript types/tests. + +- [ ] **Step 1: Python typed request test** + +Add tests: + +```python +def test_extract_agent_timeline_sends_map_raw_content(httpx_mock): + client.memory.extract_agent_timeline( + user_id="u", + agent_id="a", + timeline={ + "sourceClient": "claude-code", + "sessionId": "s", + "timelineId": "t", + "events": [], + }, + source_client="claude-code", + ) + payload = sent_json() + assert payload["rawContent"]["type"] == "agent_timeline" + assert payload["rawContent"]["sessionId"] == "s" +``` + +Add async equivalent. + +- [ ] **Step 2: Implement Python convenience wrapper** + +In sync/async resources: + +```python +def extract_agent_timeline(self, *, user_id: str, agent_id: str, timeline: dict[str, Any], source_client: str | None = None) -> ExtractMemoryResponse: + raw_content = {"type": "agent_timeline", **timeline} + return self.extract(user_id=user_id, agent_id=agent_id, raw_content=raw_content, source_client=source_client) +``` + +Do not block early adopters on typed models. + +- [ ] **Step 3: Java serialization test** + +At minimum, add a test documenting: + +```java +MapRawContent.of("agent_timeline", Map.of("sessionId", "s", "timelineId", "t", "events", List.of())) +``` + +serializes with `"type":"agent_timeline"`. + +If adding typed classes, add `AgentTimelineContent`, `AgentEvent`, and tests. + +- [ ] **Step 4: TypeScript type/serialization test** + +Add: + +```ts +const raw: RawContentValue = { + type: 'agent_timeline', + sessionId: 's', + timelineId: 't', + events: [], +} +``` + +Assert request serialization preserves the payload. + +- [ ] **Step 5: Run client tests** + +Run: + +```bash +UV_CACHE_DIR=.uv-cache uv run --python /opt/homebrew/bin/python3.12 --extra dev pytest memind-clients/python/tests -q +mvn -pl memind-clients/java/memind-client test +PATH=/Users/zhengyate/.nvm/versions/node/v22.22.0/bin:$PATH COREPACK_HOME=/tmp/memind-corepack pnpm --dir memind-clients/typescript test +``` + +Expected: tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add memind-clients/python memind-clients/java/memind-client memind-clients/typescript +git commit -m "feat(clients): add agent timeline helpers" +``` + +### Task 15: Add Claude Code Timeline Capture + +**Files:** +- Modify/create Claude Code integration files listed in File Structure. + +- [ ] **Step 1: Write timeline unit tests** + +Create `test_agent_timeline.py`: + +```python +def test_normalizes_post_tool_use_to_command_event(): + event = normalize_hook_event({ + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "Bash", + "tool_input": {"command": "npm test payment"}, + "tool_response": {"exit_code": 1, "stdout": "rounding mismatch"}, + "timestamp": "2026-05-24T10:00:00Z", + }, seq=1) + assert event["kind"] == "command" + assert event["command"] == "npm test payment" + assert event["status"] == "failed" +``` + +Test redaction before spool: + +```python +assert "sk-" not in json.dumps(event) +assert "[REDACTED" in json.dumps(event) +``` + +Test flush payload: + +```python +payload = build_timeline_payload( + config={"sourceClient": "claude-code"}, + identity={"userId": "u", "agentId": "a"}, + session_id="s", + events=[event], + hook_input={"cwd": "/tmp/project"}, +) +assert payload["type"] == "agent_timeline" +assert payload["sessionId"] == "s" +assert payload["events"][0]["seq"] == 1 +``` + +- [ ] **Step 2: Implement `agent_timeline.py`** + +Functions: + +- `normalize_hook_event(hook_input: dict, seq: int) -> dict` +- `append_event(state, event)` +- `build_timeline_payload(config, identity, session_id, events, hook_input) -> dict` +- `redact_text(text: str) -> tuple[str, list[str]]` +- `event_id(source_client, session_id, seq, hook_input) -> str` + +Store only normalized, redacted fields. + +- [ ] **Step 3: Extend `SessionState`** + +Add: + +```python +self.data.setdefault("agentEvents", []) +self.data.setdefault("nextAgentSeq", 1) +``` + +Methods: + +- `append_agent_event(event)` +- `agent_events()` +- `clear_agent_events(event_ids)` +- `next_agent_seq()` + +Add a soft buffer cap to prevent very long sessions from growing state without bound: + +```python +MAX_AGENT_EVENTS = 500 + +def append_agent_event(self, event): + events = list(self.data.get("agentEvents", [])) + if any(existing.get("eventId") == event.get("eventId") for existing in events): + return + events.append(event) + if len(events) > MAX_AGENT_EVENTS: + events = events[-MAX_AGENT_EVENTS:] + self.data["agentEventsTruncated"] = True + self.data["agentEvents"] = events + self.data["updatedAt"] = time.time() +``` + +This cap is a local integration guard only. Server-side episode size is still controlled by `maxEventsPerEpisode`, `maxEventGap`, and chunking options. + +Keep transcript submitted fingerprints behavior unchanged. + +- [ ] **Step 4: Add hook scripts and hooks.json entries** + +Add `PreToolUse` and `PostToolUse` entries: + +```json +"PreToolUse": [{"hooks": [{"type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/pre_tool_use.py\"", "timeout": 5, "async": true}]}], +"PostToolUse": [{"hooks": [{"type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/post_tool_use.py\"", "timeout": 5, "async": true}]}] +``` + +Scripts must fail open and print: + +```json +{"continue": true} +``` + +- [ ] **Step 5: Flush timeline in existing flush scripts** + +In `ingest.py`, after transcript extraction attempt, flush buffered agent events when: + +- `config.get("autoIngestAgentTimeline", True)` is true. +- Events exist. + +Call: + +```python +await client.extract(identity["userId"], identity["agentId"], timeline_payload, source_client) +``` + +On non-success/exception, spool a full extract payload: + +```json +{ + "kind": "extract", + "userId": "u", + "agentId": "a", + "sourceClient": "claude-code", + "sessionId": "s", + "eventIds": ["e1", "e2"], + "rawContent": { + "type": "agent_timeline", + "sourceClient": "claude-code", + "sessionId": "s", + "agentTurnId": "s-agent-turn-1-1", + "timelineId": "s-stop", + "events": [ + {"eventId": "e1", "seq": 1, "kind": "command", "command": "npm test payment", "status": "failed"} + ] + } +} +``` + +Only clear events after `status == "SUCCESS"`: + +```python +state.clear_agent_events([event["eventId"] for event in events]) +``` + +Update `session_start.py` retry replay so a successful `agent_timeline` extract clears buffered event IDs: + +```python +if payload.get("sessionId") and payload.get("eventIds"): + with SessionStateStore(state_root()).locked(payload["sessionId"]) as state: + state.clear_agent_events(payload["eventIds"]) +``` + +Keep existing transcript `fingerprints` replay behavior unchanged. + +Repeat flush path in `pre_compact.py` and `session_end.py`. + +- [ ] **Step 6: Run Claude Code tests** + +Run: + +```bash +PYTHONPATH=memind-integrations/claude-code/scripts python3 -m unittest discover -s memind-integrations/claude-code/tests -v +``` + +Expected: tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add memind-integrations/claude-code +git commit -m "feat(claude-code): capture agent timelines" +``` + +### Task 16: Add Codex Timeline Capture + +**Files:** +- Modify/create Codex integration files listed in File Structure. + +- [ ] **Step 1: Port Claude Code timeline tests to Codex** + +Use Codex-specific environment names and payload fields. Tests must cover: + +- `PreToolUse` / `PostToolUse` fail open. +- Event normalization. +- Stable event IDs and sequence numbers. +- Stop flush sends `agent_timeline`. +- Retry spool stores full timeline payload. +- Retry replay clears `agentEvents` by `eventIds` only after Memind returns `SUCCESS`. + +- [ ] **Step 2: Implement Codex timeline buffer** + +Mirror Claude Code implementation, but keep Codex-specific hook root: + +```text +~/.memind/codex/state +~/.memind/codex/retry +``` + +and plugin env: + +```text +CODEX_PLUGIN_ROOT +``` + +Codex retry payloads use `sessionKey` instead of Claude Code `sessionId` when the existing state store uses `state_key(hook_input)`: + +```json +{ + "kind": "extract", + "userId": "u", + "agentId": "a", + "sourceClient": "codex", + "sessionKey": "codex-session-key", + "eventIds": ["e1", "e2"], + "rawContent": { + "type": "agent_timeline", + "sourceClient": "codex", + "sessionId": "codex-session-key", + "timelineId": "codex-session-key-stop", + "events": [] + } +} +``` + +Update Codex `session_start.py` retry replay equivalent so a successful `agent_timeline` extract calls: + +```python +store.clear_agent_events(payload["sessionKey"], payload["eventIds"]) +``` + +or the matching locked-state helper if the implementation keeps the same `SessionStateStore.locked(...)` shape as Claude Code. + +- [ ] **Step 3: Update hooks.json** + +Add supported hooks: + +```json +"PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CODEX_PLUGIN_ROOT}/scripts/pre_tool_use.py\"", + "timeout": 5 + } + ] + } +], +"PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CODEX_PLUGIN_ROOT}/scripts/post_tool_use.py\"", + "timeout": 5 + } + ] + } +] +``` + +Keep `Stop` as primary flush. + +- [ ] **Step 4: Run Codex tests** + +Run: + +```bash +PYTHONPATH=memind-integrations/codex/scripts python3 -m unittest discover -s memind-integrations/codex/tests -v +``` + +Expected: tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add memind-integrations/codex +git commit -m "feat(codex): capture agent timelines" +``` + +### Task 17: Format Retrieved AGENT Memories for Coding Hooks + +**Files:** +- Modify: `memind-integrations/claude-code/scripts/retrieve.py` +- Modify: `memind-integrations/codex/scripts/retrieve.py` +- Tests: `test_hooks.py` in both integrations. + +- [ ] **Step 1: Write formatting tests** + +Input: + +```python +data = { + "items": [ + {"id": "1", "text": "Use npm test payment", "category": "tool", "metadata": {"toolName": "Bash"}}, + {"id": "2", "text": "Payment rounding mismatch was fixed", "category": "resolution", "metadata": {}}, + {"id": "3", "text": "When payment tests fail with rounding mismatch, inspect policy, edit calc.ts, then run npm test payment.", "category": "playbook", "metadata": {}}, + {"id": "4", "text": "Do not change public API", "category": "directive", "metadata": {}}, + ], + "insights": [], +} +``` + +Assert formatted context contains: + +```text +## Agent Playbooks +## Resolved Problems +## Tool Notes +## Directives +``` + +and no unrelated in-app explanation text. + +- [ ] **Step 2: Implement formatter** + +Change `_format_context` to group item categories: + +- `playbook` -> `## Agent Playbooks` +- `resolution` -> `## Resolved Problems` +- `tool` -> `## Tool Notes` +- `directive` -> `## Directives` + +Keep existing insight-first behavior for non-agent results. Limit by `retrieveMaxEntries` and `retrieveMaxChars`. + +- [ ] **Step 3: Run tests** + +Run: + +```bash +PYTHONPATH=memind-integrations/claude-code/scripts python3 -m unittest discover -s memind-integrations/claude-code/tests -v +PYTHONPATH=memind-integrations/codex/scripts python3 -m unittest discover -s memind-integrations/codex/tests -v +``` + +Expected: tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add memind-integrations/claude-code/scripts/retrieve.py \ + memind-integrations/claude-code/tests/test_hooks.py \ + memind-integrations/codex/scripts/retrieve.py \ + memind-integrations/codex/tests/test_hooks.py +git commit -m "feat(integrations): format agent memories" +``` + +### Task 18: Documentation and Examples + +**Files:** +- Modify: `memind-integrations/claude-code/README.md` +- Modify: `memind-integrations/codex/README.md` +- Modify: `memind-clients/python/README.md` +- Modify: `memind-clients/java/memind-client/README.md` if present, otherwise nearest Java client docs. +- Modify: `memind-clients/typescript/README.md` +- Create: `docs/superpowers/specs/2026-05-24-rawdata-agent-design.md` updates only if implementation discoveries changed design. + +- [ ] **Step 1: Add raw JSON example** + +Document: + +```json +{ + "userId": "local__alice", + "agentId": "claude-code__project_hash", + "sourceClient": "claude-code", + "rawContent": { + "type": "agent_timeline", + "sourceClient": "claude-code", + "sessionId": "session-123", + "timelineId": "timeline-123", + "events": [] + } +} +``` + +- [ ] **Step 2: Document configuration** + +Include: + +```properties +memind.rawdata.agent.enabled=true +memind.rawdata.agent.privacy.redact-secrets=true +memind.rawdata.agent.extraction.extract-on-every-tool=false +``` + +- [ ] **Step 3: Document limitations** + +State: + +- Exact duplicate complete windows are idempotent. +- Arbitrary overlapping partial windows are adapter responsibility in v1. +- File content capture is disabled by default. +- `rawdata-toolcall` remains supported. +- If `rawdata-toolcall` and `rawdata-agent` ingest the same tool activity, v1 may create semantically overlapping TOOL items. This is acceptable compatibility behavior; do not add cross-plugin suppression in v1. Users who want one canonical coding-agent path should enable `rawdata-agent` for full agent timelines and keep `rawdata-toolcall` for pure legacy tool-call logs. + +- [ ] **Step 4: Run docs formatting check** + +Run: + +```bash +git diff --check -- docs memind-integrations memind-clients +``` + +Expected: no whitespace errors. + +- [ ] **Step 5: Commit** + +```bash +git add docs memind-integrations memind-clients +git commit -m "docs: describe agent timeline ingestion" +``` + +### Task 19: End-to-End Server Acceptance Tests + +**Files:** +- Modify/create server integration tests: + - `memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerIntegrationTest.java` + - Or a new focused `AgentTimelineOpenApiIntegrationTest.java`. + +- [ ] **Step 1: Add Open API ingest test** + +POST to sync extract with: + +```json +{ + "userId": "u", + "agentId": "a", + "sourceClient": "claude-code", + "rawContent": { + "type": "agent_timeline", + "sourceClient": "claude-code", + "sessionId": "s", + "agentTurnId": "s-agent-turn-1-5", + "timelineId": "t", + "events": [ + {"eventId":"e1","seq":1,"kind":"user_prompt","text":"Fix payment tests","occurredAt":"2026-05-24T10:00:00Z"}, + {"eventId":"e2","seq":2,"kind":"command","toolName":"Bash","command":"npm test payment","status":"failed","output":"rounding mismatch","occurredAt":"2026-05-24T10:01:00Z"}, + {"eventId":"e3","seq":3,"kind":"file_edit","path":"src/payment/calc.ts","operation":"modify","occurredAt":"2026-05-24T10:02:00Z"}, + {"eventId":"e4","seq":4,"kind":"command","toolName":"Bash","command":"npm test payment","status":"success","occurredAt":"2026-05-24T10:03:00Z"}, + {"eventId":"e5","seq":5,"kind":"stop","occurredAt":"2026-05-24T10:04:00Z"} + ] + } +} +``` + +Assert: + +- response status is success. +- at least one item id exists. +- admin item read path shows category `tool` or `resolution`. +- rawdata metadata contains `segmentType=agent_episode`. + +- [ ] **Step 2: Add duplicate submission test** + +Submit the same request twice. Assert durable item count for that memory does not increase on second submit. + +- [ ] **Step 3: Add retrieval formatting metadata test** + +Retrieve query: + +```text +How should payment tests be validated? +``` + +Assert returned item includes: + +```json +"category": "tool", +"metadata": {"commands": ["npm test payment"]} +``` + +- [ ] **Step 4: Run server integration tests** + +Run: + +```bash +mvn -pl memind-server -Dtest=AgentTimelineOpenApiIntegrationTest test +``` + +Expected: tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add memind-server/src/test/java +git commit -m "test(server): cover agent timeline open api" +``` + +### Task 20: Evaluation Fixtures + +**Files:** +- Create: `evaluation/rawdata-agent/fixtures/auth-jwt-fix.json` +- Create: `evaluation/rawdata-agent/fixtures/payment-rounding-fix.json` +- Create: `evaluation/rawdata-agent/fixtures/project-directive.json` +- Create: `evaluation/rawdata-agent/README.md` +- Create: `evaluation/rawdata-agent/run-fixtures.py` if evaluation scripts are accepted in repo. + +- [ ] **Step 1: Add fixtures** + +Each fixture contains: + +- request payload. +- expected categories. +- expected retrieval query. +- expected key phrases. + +Example expected: + +```json +{ + "expectedCategories": ["tool", "resolution"], + "queries": [ + { + "query": "How do I validate payment calculation changes?", + "mustContain": ["npm test payment"] + } + ] +} +``` + +- [ ] **Step 2: Add runner** + +Runner should: + +1. POST fixture payload. +2. Retrieve query. +3. Check expected phrases/categories. +4. Print duplicate item rate. + +- [ ] **Step 3: Run fixture tests** + +Run: + +```bash +python3 evaluation/rawdata-agent/run-fixtures.py --base-url http://127.0.0.1:8366 +``` + +Expected when server is running: all fixtures pass. If no server is running, runner exits with a clear message and non-zero status. + +- [ ] **Step 4: Commit** + +```bash +git add evaluation/rawdata-agent +git commit -m "test(eval): add rawdata-agent fixtures" +``` + +### Task 21: Full Verification + +**Files:** all changed files. + +- [ ] **Step 1: Run formatting and whitespace checks** + +Run: + +```bash +git diff --check +mvn spotless:check +``` + +Expected: no whitespace or formatting errors. + +- [ ] **Step 2: Run Maven tests** + +Run: + +```bash +mvn test +``` + +Expected: all Java tests pass. + +- [ ] **Step 3: Run Python client tests** + +Run: + +```bash +UV_CACHE_DIR=.uv-cache uv run --python /opt/homebrew/bin/python3.12 --extra dev pytest memind-clients/python/tests -q +``` + +Expected: all Python client tests pass. + +- [ ] **Step 4: Run integration Python tests** + +Run: + +```bash +PYTHONPATH=memind-integrations/claude-code/scripts python3 -m unittest discover -s memind-integrations/claude-code/tests -v +PYTHONPATH=memind-integrations/codex/scripts python3 -m unittest discover -s memind-integrations/codex/tests -v +``` + +Expected: all integration tests pass. + +- [ ] **Step 5: Run TypeScript tests** + +Run: + +```bash +PATH=/Users/zhengyate/.nvm/versions/node/v22.22.0/bin:$PATH COREPACK_HOME=/tmp/memind-corepack pnpm --dir memind-clients/typescript test +``` + +Expected: all TypeScript client tests pass. + +- [ ] **Step 6: Build package** + +Run: + +```bash +mvn -DskipTests package +``` + +Expected: package succeeds. + +- [ ] **Step 7: Final commit** + +```bash +git status --short +git add memind-core \ + memind-plugins/memind-plugin-rawdatas \ + memind-plugins/memind-plugin-spring-boot-starters \ + memind-plugins/memind-plugin-jdbc \ + memind-server \ + memind-clients \ + memind-integrations \ + docs \ + evaluation/rawdata-agent +git commit -m "feat: add rawdata agent memory support" +``` + +Only commit if all prior verification steps pass. Before committing, run `git diff --cached --name-only` and unstage any unrelated user changes with `git restore --staged `. + +--- + +## Acceptance Checklist + +- [ ] `rawContent.type = "agent_timeline"` works through normal extraction endpoint. +- [ ] `AgentTimelineContentProcessor` returns AGENT categories, `usesSourceIdentity=true`, and `supportsInsight=true`. +- [ ] `agent_episode` is segment metadata, not a new first-class storage model. +- [ ] Redaction happens before Segment persistence, vectorization, and item extraction. +- [ ] TOOL items map to `tools` insight type. +- [ ] Existing stores get `tools` through idempotent reconciliation. +- [ ] Graph hints reuse `ExtractedMemoryEntry.graphHints()` and current entity/causal vocabulary. +- [ ] Exact duplicate complete timeline window does not create duplicate durable items. +- [ ] Claude Code and Codex capture tool events fail-open and flush complete timeline windows. +- [ ] Retrieved AGENT memories are grouped into Playbooks, Resolved Problems, Tool Notes, and Directives. +- [ ] `rawdata-toolcall` still works unchanged. diff --git a/docs/superpowers/specs/2026-05-24-rawdata-agent-design.md b/docs/superpowers/specs/2026-05-24-rawdata-agent-design.md new file mode 100644 index 00000000..86de9a26 --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-rawdata-agent-design.md @@ -0,0 +1,1412 @@ +# rawdata-agent Design Spec + +Date: 2026-05-24 + +Status: proposed design + +Target project: `/Users/zhengyate/dev/openmemind/memind` + +Target location after review: `docs/superpowers/specs/2026-05-24-rawdata-agent-design.md` + +## Summary + +`rawdata-agent` is a new Memind RawData plugin that lets coding agents and other tool-using agents submit their work process as structured agent timelines. The plugin turns raw agent activity into Memind's existing AGENT memory categories: `TOOL`, `RESOLUTION`, `PLAYBOOK`, and `DIRECTIVE`. + +The design deliberately reuses Memind core instead of creating a parallel observation database. Agent timelines enter the existing pipeline: + +```text + AgentTimelineContent + -> RawData / Segment / ParsedSegment + -> ExtractedMemoryEntry + -> MemoryItem + -> Insight Tree / Graph / Retrieval +``` + +This gives Memind the coding-agent capabilities that make agentmemory and claude-mem useful, while keeping Memind's broader architecture: multi-source RawData, USER and AGENT scopes, Insight Tree, deep retrieval, graph support, and reusable Spring Boot plugin wiring. + +## Motivation + +Memind already has strong general memory architecture: RawData, MemoryItem, Insight Tree, Simple/Deep retrieval, USER and AGENT scopes, item graph support, and plugin-based raw data handling. Its current Claude Code and Codex integrations, however, intentionally focus on prompt-time retrieval and transcript ingestion. They do not ingest tool calls or agent lifecycle events in v0.1. + +That leaves a gap in coding-agent scenarios. Coding agents learn from things that are often not present in final dialogue: + +- Which files were inspected or edited. +- Which commands failed and then passed. +- Which tool usage patterns are reliable. +- Which bug was resolved and how. +- Which repeated successful workflow should become a procedural playbook. +- Which durable project or collaboration rule should be reused. + +agentmemory and claude-mem address this gap with observations and tool-call history. The Memind design should borrow that lesson without copying their storage model. In Memind, the equivalent "observation layer" should be represented as agent episode segments derived from RawData. Those segments remain traceable to raw events and feed the existing MemoryItem and Insight Tree machinery. + +## Goals + +1. Add a canonical RawData plugin for agent work process data. +2. Support Claude Code, Codex, OpenClaw, Hermes Agent, Cursor, Gemini CLI, and custom agents through a common schema. +3. Capture tool usage, command results, file interactions, permissions, errors, user goals, and task outcomes. +4. Chunk event streams into coherent agent episodes rather than isolated log lines. +5. Extract AGENT memory items: + - `TOOL`: tool and command usage experience. + - `RESOLUTION`: resolved problem and fix knowledge. + - `PLAYBOOK`: reusable procedural workflows learned from successful episodes. + - `DIRECTIVE`: durable agent behavior rules and project constraints. +6. Reuse Memind core persistence, deduplication, vector indexing, text search, item graph, Insight Tree, and retrieval. +7. Preserve privacy and safety by redacting secrets and avoiding raw large-output storage in memory items. +8. Provide a path for Memind's Claude Code and Codex integrations to capture tool events without binding core to those hosts. +9. Close the coding-agent memory capability gap with agentmemory and claude-mem while keeping Memind more general. + +## Non-Goals + +1. Do not add a new standalone memory database for agent observations. +2. Do not hard-code Claude Code or Codex semantics into `memind-core`. +3. Do not require every tool event to trigger LLM extraction. +4. Do not replace `rawdata-toolcall`; keep it as a narrow compatibility path for pure tool-call logs. +5. Do not make the first version depend on a new UI. +6. Do not make `Observation` a new first-class Memind model in the first version. +7. Do not store full sensitive tool outputs or file contents as long-term memory by default. + +## Comparison With agentmemory and claude-mem + +### claude-mem + +claude-mem is strong as a coding-memory product. It has lifecycle hooks, generated observations, a worker process, SQLite/Chroma-backed search, search/timeline/detail progressive disclosure, viewer UI, and many product skills. It is particularly polished for "what happened before in this project?" workflows. + +Memind should borrow: + +- Tool/lifecycle capture as a first-class integration concern. +- Progressive disclosure for coding history retrieval. +- Compact generated episode summaries with evidence IDs. +- Product-level Claude/Codex hook reliability and retry behavior. + +Memind should not copy: + +- A separate observation-first storage model when RawData/Segment/MemoryItem already cover most needs. +- A Claude-centric design. + +### agentmemory + +agentmemory is strong as a coding-agent runtime memory system. It has rich observations, hybrid search, graph features, lessons, procedural skill extraction, retention/decay, actions, signals, checkpoints, and many MCP/REST endpoints. + +Memind should borrow: + +- Episode-level coding observations rather than only transcript summaries. +- Procedural skill extraction from completed successful sessions. +- Lessons/playbooks as reusable agent knowledge. +- Core-compatible entity hints for files, commands, errors, tools, and modules. +- Delayed extraction after episode completion instead of per-event extraction. + +Memind should not copy: + +- A coding-only architecture. +- Runtime orchestration features such as leases/actions/checkpoints as part of `rawdata-agent` v1. +- A parallel state engine outside Memind core. + +### Memind Differentiation + +With `rawdata-agent`, Memind can become a general agent experience memory layer: + +```text +conversation + document + image + audio + toolcall + agent timeline + -> MemoryItem + -> Insight Tree + -> Retrieval +``` + +The differentiator is not just saving coding observations. It is turning agent work process into structured AGENT memory that participates in Memind's long-term knowledge evolution. + +## Architecture + +### Module Layout + +Add: + +```text +memind-plugins/ + memind-plugin-rawdatas/ + memind-plugin-rawdata-agent/ + src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/ + AgentRawContentTypeRegistrar.java + content/ + AgentTimelineContent.java + AgentSkillDocContent.java # optional v1.1, not required for v1 + model/ + AgentEvent.java + AgentEventKind.java + AgentTimeline.java + AgentEpisode.java + AgentCommand.java + AgentFileReference.java + AgentToolCall.java + AgentOutcome.java + config/ + AgentRawDataOptions.java + AgentChunkingOptions.java + AgentExtractionOptions.java + AgentPrivacyOptions.java + chunk/ + AgentTimelineChunker.java + AgentEpisodeAssembler.java + AgentSegmentFormatter.java + processor/ + AgentTimelineContentProcessor.java + caption/ + AgentCaptionGenerator.java + item/ + AgentItemExtractionStrategy.java + AgentItemPrompts.java + privacy/ + AgentEventRedactor.java + SecretPatternRedactor.java + plugin/ + AgentRawDataPlugin.java + + memind-plugin-spring-boot-starters/ + memind-plugin-rawdata-agent-starter/ + src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/ + AgentRawDataAutoConfiguration.java + AgentRawDataProperties.java +``` + +Register the modules and starter in the same places as existing rawdata plugins: + +- `memind-plugins/memind-plugin-rawdatas/pom.xml` +- `memind-plugins/memind-plugin-spring-boot-starters/pom.xml` +- the parent/root module list if the repository root POM enumerates plugin modules +- `memind-server/pom.xml` +- `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` in the starter + +### Runtime Loading + +Memind server already collects `RawDataPlugin` beans through `ObjectProvider` and passes them into `Memory.builder().rawDataPlugin(...)`. `rawdata-agent` should follow the same path. + +The plugin should not require special server code for its core extraction behavior. Optional REST convenience endpoints can be added later, but v1 can use the existing extraction endpoint with `rawContent.type = "agent_timeline"`. + +### Integration Boundary + +Agent integrations are adapters. They capture host-specific events and submit normalized payloads to Memind. + +```text +Claude Code hook payload +Codex hook payload +OpenClaw event bus +Hermes trace log +Cursor transcript + -> integration adapter + -> AgentTimelineContent JSON + -> Memind server + -> rawdata-agent plugin +``` + +`rawdata-agent` owns schema validation, privacy cleanup, chunking, episode assembly, and item extraction. It does not own hook installation or host-specific event parsing. + +## Raw Content Types + +### `agent_timeline` v1 + +Primary input type. It represents a time-ordered sequence of events from one agent session, turn, task, or partial episode. + +Example: + +```json +{ + "type": "agent_timeline", + "sourceClient": "claude-code", + "sourceVersion": "1.0", + "userId": "local__alice", + "agentId": "claude-code__project_hash", + "project": { + "name": "payments-api", + "rootHash": "sha256:...", + "git": { + "branch": "fix/payment-rounding", + "commit": "abc123" + } + }, + "sessionId": "session-123", + "agentTurnId": "session-123-agent-turn-1-6", + "timelineId": "timeline-123", + "events": [ + { + "eventId": "e1", + "seq": 1, + "kind": "user_prompt", + "text": "修复 payment test", + "occurredAt": "2026-05-24T10:00:00Z" + }, + { + "eventId": "e2", + "seq": 2, + "kind": "tool_call", + "toolName": "Read", + "input": "src/payment/calc.ts", + "status": "success", + "occurredAt": "2026-05-24T10:00:12Z" + }, + { + "eventId": "e3", + "seq": 3, + "kind": "command", + "toolName": "Bash", + "command": "npm test payment", + "status": "failed", + "output": "rounding mismatch", + "durationMs": 3200, + "occurredAt": "2026-05-24T10:01:00Z" + }, + { + "eventId": "e4", + "seq": 4, + "kind": "file_edit", + "path": "src/payment/calc.ts", + "operation": "modify", + "occurredAt": "2026-05-24T10:03:00Z" + }, + { + "eventId": "e5", + "seq": 5, + "kind": "command", + "toolName": "Bash", + "command": "npm test payment", + "status": "success", + "durationMs": 4100, + "occurredAt": "2026-05-24T10:05:00Z" + }, + { + "eventId": "e6", + "seq": 6, + "kind": "stop", + "occurredAt": "2026-05-24T10:06:00Z" + } + ] +} +``` + +### Content Type Contract + +Memind currently separates external JSON discriminator names from Java internal content type constants. `rawdata-agent` must follow that convention explicitly: + +```text +External JSON rawContent.type: "agent_timeline" +RawContentTypeRegistrar subtype: "agent_timeline" -> AgentTimelineContent.class +AgentTimelineContent.TYPE: "AGENT_TIMELINE" +AgentTimelineContent.contentType(): "AGENT_TIMELINE" +RawData.contentType stored value: "AGENT_TIMELINE" +``` + +Rationale: + +- JSON subtype names use lower snake case, consistent with existing plugin subtype `tool_call`. +- Java content type constants use upper snake case, consistent with existing `ConversationContent.TYPE = "CONVERSATION"` and `ToolCallContent.TYPE = "TOOL_CALL"`. +- Client libraries can submit the feature immediately through map-based raw content without waiting for typed client models. + +### `agent_skilldoc` v1.1 + +Optional future input type for importing existing `SKILL.md`, procedure docs, and team playbooks. This is not required for historical-session procedural skill extraction. Historical skill extraction should come from `agent_timeline` episodes. + +## Event Schema + +Supported event kinds in v1: + +- `user_prompt` +- `assistant_message` +- `tool_call` +- `tool_result` +- `command` +- `file_read` +- `file_edit` +- `test_result` +- `permission_request` +- `error` +- `stop` +- `session_end` +- `task_completed` + +Required fields for all events: + +- `eventId`: stable event ID from adapter, or deterministic hash. +- `seq`: monotonic sequence number within timeline. If the host event stream does not provide one, the adapter must synthesize a stable sequence number from the normalized event order before submission. +- `kind`: event kind. +- `occurredAt`: event time, or adapter receipt time. + +Common optional fields: + +- `toolName` +- `input` +- `output` +- `status`: `success`, `failed`, `error`, `cancelled`, `unknown` +- `durationMs` +- `path` +- `operation` +- `command` +- `exitCode` +- `metadata` + +The plugin must tolerate missing optional fields and should avoid rejecting useful partial timelines unless core required fields are missing. + +## Processor Contract + +`AgentTimelineContentProcessor` must make the content behavior explicit: + +```java +contentClass() -> AgentTimelineContent.class +contentType() -> AgentTimelineContent.TYPE +allowedCategories() -> MemoryCategory.agentCategories() +usesSourceIdentity() -> true +supportsInsight() -> true +``` + +`usesSourceIdentity()` is required because two different agents, repositories, or sessions can produce identical event text. The RawData content ID must include a source identity through `resourceId`, `sourceUri`, `storageUri`, request metadata, or the content's own stable timeline fingerprint. Integration adapters should include at least: + +```json +{ + "sourceUri": "agent://claude-code/payments-api/session-123/timeline-123", + "sourceClient": "claude-code", + "sessionId": "session-123", + "timelineId": "timeline-123", + "projectRootHash": "sha256:..." +} +``` + +Current Memind open extraction requests only expose `sourceClient` as a top-level convenience field. They do not automatically promote arbitrary `rawContent` JSON fields such as `sessionId` or `timelineId` into extraction request metadata before RawData content ID calculation. Therefore `rawdata-agent` must make source identity deterministic through one of these supported paths: + +- preferred v1 path: `AgentTimelineContent.getContentId()` hashes a canonical identity payload that includes `sourceClient`, `sessionId`, `timelineId`, ordered event IDs, and a normalized event-content hash, so exact duplicate complete timeline windows produce the same RawData content ID even through the existing open extraction endpoint; +- optional API/client enhancement: extend the open extraction request and client wrappers to pass `sourceUri`, `sessionId`, `timelineId`, and `projectRootHash` as extraction metadata before `RawDataLayer.resolveRawDataContentId(...)` runs. + +If the optional metadata path is added, it must be additive and backward-compatible. The plugin must not depend on raw JSON payload fields being visible as request metadata unless that wiring is explicitly implemented. + +`supportsInsight()` should stay `true`. Disabling insight for this content type would make AGENT playbooks, resolutions, and directives persist as items only, which would weaken the main reason to add `rawdata-agent`. + +`allowedCategories()` protects the USER memory tree. Even if extraction uses `ExtractionConfig.defaults()` with USER scope, AGENT categories should be emitted and `MemoryItemLayer` should persist them under their category-defined AGENT scope. Callers should still prefer `ExtractionConfig.agentOnly()` or the equivalent server/client option when available to make intent explicit. + +## Chunking and Episode Assembly + +### Principle + +Chunking must preserve causality. A coding-agent memory chunk should describe a coherent task episode, not an arbitrary token window. + +Bad chunking: + +```text +Segment 1: Bash npm test failed. +Segment 2: Edit calc.ts. +Segment 3: Bash npm test passed. +``` + +Good chunking: + +```text +Segment: User asked to fix payment tests. npm test payment failed with rounding mismatch. The agent edited src/payment/calc.ts. npm test payment then passed. +``` + +### Episode Boundaries + +Primary boundaries: + +- `user_prompt` starts a candidate episode. +- `stop`, `session_end`, or `task_completed` ends a candidate episode. +- A new `user_prompt` closes the previous open episode if no stop event was seen. + +Secondary boundaries: + +- Time gap larger than `maxEventGap`, default 30 minutes. +- Token estimate larger than `targetEpisodeTokens`, default 2000. +- Event count larger than `maxEventsPerEpisode`, default 80. +- Explicit adapter-provided `taskId` or `subtaskId` changes. + +### Phase Splitting + +If an episode exceeds budget, split by phase while preserving shared context: + +- `investigation`: reads, searches, failed commands, diagnostics. +- `implementation`: edits, patches, refactors. +- `validation`: tests, builds, checks, final verification. +- `handoff`: summary, stop, session end. + +Each phase segment must include: + +- `episodeId` +- `goal` +- `phase` +- `outcome` +- important files +- important commands +- evidence event IDs + +### Segment Text Format + +`AgentSegmentFormatter` should produce compact, deterministic text: + +```text +Goal: Fix payment tests. +Outcome: success +Project: payments-api +Files: src/payment/calc.ts +Commands: +- npm test payment -> failed: rounding mismatch +- npm test payment -> success +Actions: +- Read src/payment/calc.ts +- Modified src/payment/calc.ts +Evidence: +- e3: failed test output mentioned rounding mismatch +- e5: npm test payment passed +``` + +### Segment Metadata + +Each segment metadata should include: + +```json +{ + "segmentType": "agent_episode", + "sourceClient": "claude-code", + "sessionId": "session-123", + "timelineId": "timeline-123", + "episodeId": "episode-123", + "phase": "full", + "goal": "Fix payment tests", + "outcome": "success", + "projectName": "payments-api", + "projectRootHash": "sha256:...", + "gitBranch": "fix/payment-rounding", + "files": ["src/payment/calc.ts"], + "commands": ["npm test payment"], + "toolNames": ["Read", "Bash", "Edit"], + "failureSignals": ["rounding mismatch"], + "eventIds": ["e1", "e2", "e3", "e4", "e5", "e6"], + "windowStart": "2026-05-24T10:00:00Z", + "windowEnd": "2026-05-24T10:06:00Z" +} +``` + +Do not persist absolute local project roots by default. They can expose usernames, customer names, or private filesystem layout through durable RawData metadata. If an integration needs raw absolute paths for a local-only deployment, make it an explicit opt-in field such as `projectRootRaw`, disabled by default. + +This metadata is the Memind equivalent of an observation evidence layer. It avoids adding a new `Observation` table while preserving traceability. + +## Observation Layer Decision + +Do not add a first-class `Observation` model in v1. + +Memind already has: + +- RawData for original input and traceability. +- Segment / ParsedSegment for chunked evidence. +- MemoryItem for durable atomic memory. +- Insight for higher-level synthesis. + +`rawdata-agent` should use `segmentType = "agent_episode"` metadata to represent observation-like units. If future UI, citation, replay, or manual-edit workflows require a durable first-class episode projection, add `AgentEpisode` later as a projection derived from RawData, not as a replacement for MemoryItem. + +Future optional projection: + +```text +AgentEpisode: + id + memoryId + rawDataId + sessionId + sourceClient + goal + outcome + summary + files + commands + eventIds + createdAt +``` + +## Item Extraction + +### Strategy + +`AgentItemExtractionStrategy` should be content-type-specific. The default conversation item extractor is not enough because agent timelines need coding-domain semantics. + +The strategy should combine deterministic extraction and LLM extraction: + +Deterministic extraction: + +- Tool and command usage facts. +- Files touched. +- Test commands and status. +- Outcome. +- Event IDs and evidence metadata. +- Core-compatible entity hints for files, commands, tools, errors, and modules. + +LLM extraction: + +- Resolved problem and fix summaries. +- Reusable playbooks. +- Durable directives. +- Normalized failure modes. +- Concise content suitable for long-term memory. + +### Output Contract + +The strategy outputs `ExtractedMemoryEntry` objects. + +Required fields: + +- `content` +- `confidence` +- `observedAt` +- `rawDataId` +- `insightTypes` +- `metadata` +- `type` +- `category` + +Structured LLM extraction should use a constrained response schema before conversion to `ExtractedMemoryEntry`: + +```json +{ + "items": [ + { + "category": "resolution", + "content": "Payment tests failed because rounding behavior in src/payment/calc.ts did not match the expected policy; editing calc.ts and rerunning npm test payment resolved the failure.", + "confidence": 0.86, + "insightTypes": ["resolutions"], + "entities": [ + {"entityType": "object", "name": "src/payment/calc.ts", "salience": 0.9}, + {"entityType": "object", "name": "npm test payment", "salience": 0.8}, + {"entityType": "concept", "name": "rounding mismatch", "salience": 0.8} + ], + "metadata": { + "episodeId": "episode-123", + "outcome": "success", + "files": ["src/payment/calc.ts"], + "commands": ["npm test payment"], + "toolNames": ["Bash", "Read", "Edit"], + "failureSignals": ["rounding mismatch"], + "evidenceEventIds": ["e3", "e4", "e5"], + "codingEntities": { + "files": ["src/payment/calc.ts"], + "commands": ["npm test payment"], + "errors": ["rounding mismatch"], + "tools": ["Bash", "Read", "Edit"] + } + } + } + ] +} +``` + +This schema intentionally uses the existing `MemoryItemExtractionResponse.ExtractedItem` shape so the agent extractor can share the same structured response vocabulary as the default item extractor. Coding-specific typed details stay in metadata. Do not introduce a plugin-owned graph payload with custom relation names in v1. + +Implementation note: `AgentItemExtractionStrategy` is content-type-specific and ultimately returns `ExtractedMemoryEntry` objects. Therefore it must not assume that core will automatically convert its intermediate LLM response into graph hints unless it routes through shared conversion code. The implementation must either: + +- extract a small core support converter from the existing default item extractor path and reuse it, or +- explicitly convert `ExtractedItem.entities` and `ExtractedItem.causalRelations` into `ExtractedGraphHints` when building each `ExtractedMemoryEntry`. + +Do not call private methods or duplicate hidden behavior through reflection. The graph hint conversion should be a normal, testable code path. + +If a single episode produces multiple memory items and there is a true item-to-item causal relationship, the extractor may emit current-core-compatible causal relations: + +```json +{ + "items": [ + { + "category": "resolution", + "content": "Payment tests failed because rounding behavior did not match policy.", + "confidence": 0.82, + "insightTypes": ["resolutions"], + "entities": [{"entityType": "concept", "name": "rounding mismatch", "salience": 0.8}], + "metadata": {"evidenceEventIds": ["e3"]} + }, + { + "category": "resolution", + "content": "Editing src/payment/calc.ts and rerunning npm test payment resolved the failure.", + "confidence": 0.86, + "insightTypes": ["resolutions"], + "entities": [{"entityType": "object", "name": "src/payment/calc.ts", "salience": 0.9}], + "causalRelations": [ + {"causeIndex": 0, "effectIndex": 1, "relationType": "enabled_by", "strength": 0.7} + ], + "metadata": {"evidenceEventIds": ["e4", "e5"]} + } + ] +} +``` + +`causeIndex` and `effectIndex` reference memory item indexes in the same response, not entity indexes. Relation types must be limited to the current Memind causal vocabulary: `caused_by`, `enabled_by`, and `motivated_by`. + +Custom coding relation names should not be emitted as graph hints in v1. Semantics such as "fixed error", "touched file", or "validated module" should be represented through item content and metadata fields such as `failureSignals`, `files`, `commands`, `toolNames`, and `codingEntities`. + +Rules: + +- Every LLM-produced item must reference at least one `metadata.evidenceEventIds` value present in the segment metadata. +- The extractor must drop items whose category is not one of `tool`, `resolution`, `playbook`, or `directive`. +- The extractor must drop playbooks without trigger, steps, and expected outcome. +- The extractor must drop resolutions without both problem and fix/conclusion. +- Deterministic metadata such as `episodeId`, `sessionId`, `timelineId`, `sourceClient`, files, commands, and event IDs should be merged into every emitted item before persistence. +- Core-compatible graph hints are optional. When present, entity types must map to current Memind graph entity types, and causal relations must use current Memind causal relation codes. A custom agent extraction strategy must populate `ExtractedMemoryEntry.graphHints()` explicitly or through a shared core converter. + +`MemoryItemLayer` then handles: + +- category validation against `allowedCategories` +- insight type normalization +- self-verification if enabled +- deduplication +- vectorization +- persistence +- graph materialization + +### Allowed Categories + +`AgentTimelineContentProcessor.allowedCategories()` must return: + +```java +MemoryCategory.agentCategories() +``` + +This prevents agent execution traces from being misclassified as USER profile/event memory. + +### Category to Insight Type Mapping + +`rawdata-agent` should assign insight types deterministically from category unless a specialized extractor has a stronger reason: + +```text +category=directive -> insightTypes=["directives"] +category=playbook -> insightTypes=["playbooks"] +category=resolution -> insightTypes=["resolutions"] +category=tool -> insightTypes=["tools"] +``` + +### Category Mapping + +#### TOOL + +Use when the memory describes tool, command, or execution behavior. + +Required signal: + +- `toolName` or `command` + +Examples: + +```text +Use npm test payment to validate changes in src/payment/calc.ts. +``` + +```text +Bash command npm test payment failed with rounding mismatch before the fix and passed afterward. +``` + +Metadata: + +```json +{ + "toolName": "Bash", + "command": "npm test payment", + "files": ["src/payment/calc.ts"], + "successCount": 1, + "failCount": 1 +} +``` + +#### RESOLUTION + +Use when the memory describes a resolved problem pattern with usable fix or conclusion. + +Required signals: + +- problem/failure mode +- fix/conclusion +- outcome or evidence + +Example: + +```text +Payment tests failed because rounding behavior in src/payment/calc.ts did not match the expected policy; editing calc.ts and rerunning npm test payment resolved the failure. +``` + +Metadata: + +```json +{ + "problem": "rounding mismatch", + "fix": "updated payment calculation logic", + "outcome": "success", + "files": ["src/payment/calc.ts"], + "commands": ["npm test payment"], + "evidenceEventIds": ["e3", "e4", "e5"] +} +``` + +#### PLAYBOOK + +Use when the memory describes a reusable procedural workflow. + +Required signals: + +- trigger condition +- at least two concrete steps +- expected outcome +- preferably success outcome + +Example: + +```text +When payment tests fail with rounding mismatch, inspect the payment calculation policy, edit src/payment/calc.ts if needed, then run npm test payment until it passes. +``` + +Metadata: + +```json +{ + "trigger": "payment tests fail with rounding mismatch", + "steps": [ + "Inspect payment calculation policy", + "Check src/payment/calc.ts", + "Run npm test payment after edits" + ], + "expectedOutcome": "payment tests pass", + "sourceEpisodeIds": ["episode-123"], + "strength": 0.6 +} +``` + +#### DIRECTIVE + +Use when the memory describes a durable rule, collaboration boundary, or project constraint. + +Example: + +```text +Do not change the public payment calculation API without updating payment integration tests. +``` + +Metadata: + +```json +{ + "scope": "project", + "files": ["src/payment/calc.ts"], + "sourceEpisodeIds": ["episode-123"] +} +``` + +## Insight Type Mapping + +Memind already has AGENT insight types: + +- `directives` +- `playbooks` +- `resolutions` + +Mapping: + +```text +category=directive -> insightTypes=["directives"] +category=playbook -> insightTypes=["playbooks"] +category=resolution -> insightTypes=["resolutions"] +``` + +Current default insight types do not include `tools`, and current `rawdata-toolcall` disables insight building. For `rawdata-agent`, tool usage is not secondary evidence; it is one of the main coding-agent memory surfaces. The v1 implementation must add a default AGENT insight type: + +```text +name: tools +categories: ["tool"] +scope: AGENT +mode: BRANCH +description: Tool and command usage patterns, grouped by toolName or command family. +``` + +Map: + +```text +category=tool -> insightTypes=["tools"] +``` + +This is a small core-level enhancement, not a separate graph engine. It requires updating `DefaultInsightTypes.all()`, store initialization tests, prompt expectations where default insight sets are asserted, and any docs that list built-in insight types. + +Existing stores need special handling. Current store implementations seed `DefaultInsightTypes.all()` when the insight type table is first created; adding `tools` only to the default list is not enough for already-initialized deployments. The implementation must add a migration-safe, idempotent default insight type reconciliation path so existing SQLite, MySQL, PostgreSQL, and in-memory stores can receive the new built-in `tools` type without overwriting user-customized insight types. + +If TOOL items persist with `insightTypes=[]`, the v1 implementation should be considered incomplete because tool usage knowledge would not participate in Insight Tree synthesis. + +This makes tool usage knowledge more discoverable without relying only on item retrieval. + +## Procedural Skill Extraction + +Procedural skill extraction should not be a separate `rawdata-skills` path for historical sessions. Historical procedural skills are outputs of successful agent episodes. + +In Memind terminology, these should be `AGENT / PLAYBOOK` memory items. + +Extraction policy: + +- Only attempt playbook extraction from successful or partially successful episodes by default. +- Require at least `minEventsForPlaybook`, default 5. +- Require a clear trigger, steps, and expected outcome. +- Do not extract playbooks from exploratory sessions without a reusable procedure. +- Avoid duplicate playbooks through deterministic content, existing deduplication, and source metadata. +- Reinforce existing similar playbooks only when an explicit merge/update path exists. + +Example: + +```text +Trigger: JWT expiry tests fail around token TTL or fake timer behavior. +Steps: +1. Inspect token TTL configuration in auth/session.ts. +2. Check fake timer setup in auth tests. +3. Run npm test auth after changes. +Expected outcome: Auth tests pass and JWT expiry behavior matches project policy. +``` + +Store as: + +```text +category = playbook +insightTypes = ["playbooks"] +metadata.sourceEpisodeIds = [...] +metadata.steps = [...] +metadata.trigger = ... +metadata.expectedOutcome = ... +``` + +The first implementation should not overclaim automatic reinforcement. Existing item deduplication can skip exact or semantically duplicate items depending on the configured deduplicator, but it does not by itself update `strength`, `sourceEpisodeIds`, or other metadata on an existing item. If v1 needs reinforcement semantics, add a dedicated playbook consolidation step that updates the existing item or Insight Buffer explicitly; otherwise treat reinforcement as Phase 5 tuning. + +## Extraction Timing + +Do not extract long-term memory on every tool event. + +Recommended lifecycle: + +```text +PostToolUse: + collect event into integration-local timeline buffer or retry spool + no LLM item extraction required per event + +Stop: + submit or flush the completed timeline window to Memind + assemble episode + extract TOOL + extract RESOLUTION only when resolved-problem evidence is present + optionally extract PLAYBOOK candidate if success and complexity thresholds pass + +PreCompact: + flush open episode before host context compaction + +SessionEnd: + flush remaining events + +Background / scheduled consolidation: + build Insight Tree + optionally merge duplicate playbooks if an explicit consolidation job exists + optionally strengthen repeated procedures if an explicit update path exists +``` + +Default config: + +```properties +memind.rawdata.agent.enabled=true +memind.rawdata.agent.extract-on-stop=true +memind.rawdata.agent.extract-on-session-end=true +memind.rawdata.agent.extract-on-every-tool=false +memind.rawdata.agent.min-events-for-extraction=3 +memind.rawdata.agent.min-events-for-playbook=5 +memind.rawdata.agent.require-success-for-playbook=true +memind.rawdata.agent.max-event-gap=PT30M +memind.rawdata.agent.consolidation-interval=PT2H +``` + +The plugin itself can process whatever payload is submitted. The integration decides when to submit. For Claude Code and Codex, the preferred first implementation is submit on `Stop`, `PreCompact`, and `SessionEnd`, with optional lightweight per-tool event spooling. + +v1 should not add server-side append or event-buffer semantics. The payload submitted to Memind should be a complete timeline window. Local adapters own partial event buffering, retry, and replay. This keeps the server aligned with the existing RawData extraction model and avoids introducing a second stateful ingestion protocol before there is a clear need. + +## Privacy and Redaction + +`rawdata-agent` must redact sensitive data before segment formatting, RawData persistence, vectorization, and item extraction. + +This is a hard requirement because the current RawData pipeline persists the `Segment` produced by `chunk(...)`. If the plugin only redacts inside the item extraction prompt, the original sensitive tool output can still be stored in RawData and vectors. Therefore redaction must happen before `AgentTimelineChunker` returns durable segments, and integrations should also redact obvious secrets before writing local retry spool files. + +Default redaction: + +- API keys and bearer tokens. +- Common secret env vars. +- Database URLs with credentials. +- SSH/private key material. +- Cloud credentials. +- Long command outputs beyond configured limit. +- File contents unless explicitly allowed. + +Config: + +```properties +memind.rawdata.agent.privacy.redact-secrets=true +memind.rawdata.agent.privacy.max-output-chars=4000 +memind.rawdata.agent.privacy.max-input-chars=2000 +memind.rawdata.agent.privacy.capture-file-content=false +memind.rawdata.agent.privacy.allow-path-patterns= +memind.rawdata.agent.privacy.deny-path-patterns=.env,*.pem,*.key +``` + +If redaction changes content, metadata should include: + +```json +{ + "redacted": true, + "redactionKinds": ["api_key", "database_url"] +} +``` + +Raw event payloads submitted by integrations should follow the same rule. Default v1 behavior should not persist full command output or file contents. Store bounded summaries, status, exit code, hashes, file paths, and short redacted snippets instead. + +## Deduplication and Idempotency + +Integrations may retry submissions. Hooks may fire repeatedly. The plugin must be idempotency-friendly. + +Identity fields: + +- `timelineId` +- `sessionId` +- `event.eventId` +- `event.seq` +- `sourceClient` +- `contentHash` + +Episode ID should be deterministic: + +```text +hash(sourceClient + sessionId + firstEventId + lastEventId + eventIds) +``` + +Item content should be deterministic enough for existing MemoryItem deduplication to work. Metadata should preserve source IDs so future consolidation can identify repeated procedures and related source episodes. + +Exact duplicate submissions of the same complete timeline window should be treated as idempotent: same `agentTurnId`, same `timelineId`, same ordered event IDs, same content hash, and same deterministic episode IDs should not produce duplicate durable items. Overlapping or partial windows are harder: the adapter must keep a watermark or window identity so retries and later flushes can be distinguished. The plugin should normalize and skip duplicate events within one submitted payload by `sourceClient + sessionId + agentTurnId + timelineId + event.eventId`; it should not claim universal deduplication or server-side merging for arbitrarily overlapping windows without adapter identity. + +Item-level idempotency must be explicit. Current Memind RawData idempotency can replay existing segments for an already-seen content ID, and item extraction may still run after RawData replay. Existing item deduplication is content-hash based and cannot guarantee duplicate suppression if an LLM produces slightly different wording on retry. + +For `agent_timeline`, implement at least one of these v1 safeguards: + +- skip item extraction when the RawData result is an exact duplicate replay for `AGENT_TIMELINE`, if the core extraction flow exposes that state to the processor or item extraction layer; or +- make `AgentItemExtractionStrategy` produce deterministic canonical content and/or deterministic item-level dedup metadata based on `episodeId + phase + category + evidenceEventIds + normalized payload`, so exact duplicate complete timeline windows yield the same item content hashes; or +- add a plugin-compatible item idempotency key that is checked before persistence. + +The acceptance target is exact duplicate complete-window idempotency. Arbitrary overlapping windows remain an adapter responsibility unless a future server-side append/window protocol is introduced. + +## Graph Hints + +`rawdata-agent` must reuse Memind's current item graph path. It should not introduce `AgentGraphHintsBuilder`, a plugin-owned graph schema, or coding-specific relation types in v1. + +The supported path is: + +```text +AgentItemExtractionStrategy + -> optional MemoryItemExtractionResponse.ExtractedItem.entities as intermediate schema + -> shared converter or explicit conversion + -> ExtractedMemoryEntry.graphHints().entities + -> existing item graph materialization +``` + +and, only when there is a true item-to-item causal link: + +```text +AgentItemExtractionStrategy + -> optional MemoryItemExtractionResponse.ExtractedItem.causalRelations as intermediate schema + -> shared converter or explicit conversion + -> ExtractedMemoryEntry.graphHints().causalRelations + -> CausalHintNormalizer +``` + +The `MemoryItemExtractionResponse.ExtractedItem` shape is an intermediate LLM response contract, not a persistence contract. The persisted handoff to the existing graph layer is `ExtractedMemoryEntry.graphHints()`. + +Coding-domain objects should be represented with the current `GraphEntityType` vocabulary: + +```text +file path -> OBJECT +command -> OBJECT +tool name -> OBJECT +module/package -> CONCEPT or OBJECT, depending on context +failure signal -> CONCEPT +framework/library -> ORGANIZATION, OBJECT, or CONCEPT, depending on how the source names it +``` + +The original typed coding semantics stay in item metadata: + +```json +{ + "files": ["src/payment/calc.ts"], + "commands": ["npm test payment"], + "toolNames": ["Bash", "Read", "Edit"], + "failureSignals": ["rounding mismatch"], + "codingEntities": { + "files": ["src/payment/calc.ts"], + "commands": ["npm test payment"], + "errors": ["rounding mismatch"], + "tools": ["Bash", "Read", "Edit"], + "modules": ["payment"] + } +} +``` + +Do not emit these as graph relation types: + +```text +FIXES +TOUCHES_FILE +VALIDATES +APPLIES_TO +EXECUTES +``` + +Those are useful coding semantics, but the current Memind graph layer only supports generic entities plus bounded causal item links. Express the coding semantics in memory content and metadata first. Use `caused_by`, `enabled_by`, or `motivated_by` only for causal item-to-item links that meet existing `CausalHintNormalizer` constraints. + +## Retrieval and Prompt Injection + +`rawdata-agent` becomes valuable only if integrations retrieve and format AGENT memory effectively. + +Claude Code and Codex retrieval hooks should format AGENT memory separately from USER/project memories: + +```text + +Relevant memories from Memind. Use only when directly helpful: + +## Agent Playbooks +- When JWT expiry tests fail, inspect fake timers, check token TTL config, then run npm test auth. + +## Resolved Problems +- Payment rounding mismatch was fixed in src/payment/calc.ts and validated with npm test payment. + +## Tool Notes +- Use npm test payment to validate payment calculation changes. + +## Directives +- Do not change public payment API without updating integration tests. + +``` + +Retrieval should support the filters Memind already exposes well: + +- by AGENT scope +- by category +- by time range when available + +The item content and metadata should still include project name, project root hash, source client, session, files, modules, and commands so lexical/vector retrieval, graph expansion, admin tooling, and integration-side grouping can use those signals. Do not require new core metadata-filter semantics for v1 unless they are implemented as a separate retrieval enhancement. + +If core retrieval does not yet expose enough category-aware formatting or metadata filtering, integration formatting should use returned item metadata to group results and should rely on query text plus AGENT/category filters for the first release. + +## API and Client Changes + +### Preferred v1 Path + +Use existing extraction endpoint with a new raw content type: + +```text +POST /open/v1/memory/async/extract +rawContent.type = "agent_timeline" +``` + +This keeps the feature aligned with Memind's RawData abstraction. + +### Optional Convenience Endpoint + +Add later if needed: + +```text +POST /open/v1/agent/timeline +``` + +This endpoint should translate request body into `AgentTimelineContent` and call the same memory extraction service. It must not bypass core extraction. + +### Client Libraries + +Python client should expose: + +```python +await client.memory.extract_agent_timeline( + user_id=..., + agent_id=..., + timeline=..., + source_client="claude-code", +) +``` + +This should be a convenience wrapper around `extract(raw_content=MapRawContent(type="agent_timeline", properties={...}))`. + +Typed models can be added after the server plugin lands: + +- Python: `AgentTimelineContent`, `AgentEvent`, and `RawContentValue = ConversationContent | MapRawContent | AgentTimelineContent`. +- Java: `AgentTimelineContent` client model or a documented `MapRawContent.of("agent_timeline", properties)` example. +- TypeScript integrations: plain JSON is acceptable in v1 as long as tests validate the exact serialized payload. + +The compatibility path matters because existing Python and Java clients already support arbitrary map-backed raw content. `rawdata-agent` should not require a client release before early adopters can test the server plugin. + +This compatibility still depends on server-side registration. Map-backed clients can serialize the payload immediately, but the Memind server must load `AgentRawContentTypeRegistrar` through the rawdata plugin or starter so Jackson can resolve `rawContent.type = "agent_timeline"` into `AgentTimelineContent`. Without the registrar, clients should expect a normal unsupported raw content type error rather than a silent fallback. + +## Claude Code and Codex Integration Changes + +### Current Behavior + +Current Memind integrations retrieve before user prompts and ingest transcript user/assistant messages after turns. They intentionally do not ingest tool calls in v0.1. + +### Required Additions + +Claude Code integration: + +- Add `PreToolUse` and `PostToolUse` hook scripts. +- Optionally add `PermissionRequest`. +- Keep `Stop`, `PreCompact`, and `SessionEnd` as flush points. +- Maintain retry spool under `~/.memind/claude-code/retry/`. +- Avoid blocking hot tool paths; per-tool scripts should be best-effort and short-timeout. + +Codex integration: + +- Add supported hook scripts for `PreToolUse`, `PostToolUse`, and optionally `PermissionRequest` where available. +- Keep `Stop` as the primary flush point. +- Maintain retry spool under `~/.memind/codex/retry/`. + +Adapter responsibilities: + +- Normalize host payload into agent timeline events. +- Generate stable event IDs and sequence numbers. +- Redact obvious local secrets before writing retry spool when possible. +- Submit complete timeline windows on flush. +- Fail open so agent workflows are not blocked by Memind downtime. + +## Relationship With `rawdata-toolcall` + +Keep `rawdata-toolcall`. + +Roles: + +```text +rawdata-toolcall + narrow path for systems that only have tool-call logs + +rawdata-agent + canonical path for complete agent work process timelines +``` + +Long-term, `rawdata-toolcall` can internally adapt pure tool-call records into `agent_timeline` episodes to reuse the same item extraction logic. This avoids duplicate tool extraction prompts and inconsistent TOOL memories. + +## Configuration + +Recommended properties: + +```properties +memind.rawdata.agent.enabled=true + +memind.rawdata.agent.chunking.target-episode-tokens=2000 +memind.rawdata.agent.chunking.hard-max-tokens=4000 +memind.rawdata.agent.chunking.max-events-per-episode=80 +memind.rawdata.agent.chunking.max-event-gap=PT30M + +memind.rawdata.agent.extraction.extract-tool=true +memind.rawdata.agent.extraction.extract-resolution=true +memind.rawdata.agent.extraction.extract-playbook=true +memind.rawdata.agent.extraction.extract-directive=true +memind.rawdata.agent.extraction.extract-on-every-tool=false +memind.rawdata.agent.extraction.min-events-for-extraction=3 +memind.rawdata.agent.extraction.min-events-for-playbook=5 +memind.rawdata.agent.extraction.require-success-for-playbook=true + +memind.rawdata.agent.privacy.redact-secrets=true +memind.rawdata.agent.privacy.max-input-chars=2000 +memind.rawdata.agent.privacy.max-output-chars=4000 +memind.rawdata.agent.privacy.capture-file-content=false +memind.rawdata.agent.privacy.deny-path-patterns=.env,*.pem,*.key +``` + +## Testing Strategy + +### Unit Tests + +Add tests for: + +- `AgentTimelineContent` JSON serialization/deserialization. +- `AgentRawContentTypeRegistrar`. +- `RawContentJackson.registerPluginSubtypes(...)` maps JSON `agent_timeline` to `AgentTimelineContent`. +- `AgentTimelineChunker`. +- Episode boundary detection. +- Phase splitting for large episodes. +- Secret redaction. +- Deterministic episode IDs. +- Category mapping. +- Insight type mapping. +- Core-compatible entity and causal graph hint construction. +- Tool-only timeline behavior. +- Unsuccessful episode behavior. +- Playbook extraction gating. +- Absolute project roots are not persisted by default; `projectRootRaw` requires explicit opt-in. + +### Integration Tests + +Add plugin integration tests: + +- `Memory.builder().rawDataPlugin(new AgentRawDataPlugin(...))` accepts `agent_timeline`. +- Extracting a successful coding timeline produces TOOL items. +- Extracting a coding timeline with explicit failure, fix/conclusion, and later validation evidence produces RESOLUTION items. +- Successful complex episode can produce PLAYBOOK. +- Failed/unresolved episode does not produce PLAYBOOK by default. +- Items are stored under AGENT scope categories only. +- Exact duplicate complete timeline window submission does not duplicate durable memory items. +- Insight building includes playbooks/resolutions/directives. +- `tools` insight type is used for TOOL items. +- JDBC JSON codec round-trips `AgentTimelineContent` when plugin subtype registration is active. +- SQLite/MySQL/Postgres store integration tests continue to read existing RawData rows after the new subtype is registered. + +### Client/Hook Tests + +Claude Code and Codex integration tests should verify: + +- Hook payload normalization. +- Retry spool persistence and replay. +- Stop flush builds one timeline payload. +- Existing transcript ingestion still works. +- Tool event ingestion can be disabled. +- Missing transcript/tool fields fail open. +- Secrets are not written to debug logs in plain text. + +### Evaluation + +Create coding-agent memory evaluation fixtures: + +- Auth/JWT failing test fixed and reused later. +- Payment rounding bug resolved and later queried. +- Project directive learned and enforced later. +- Command validation memory retrieved for related file. +- Repeated workflows consolidated into playbook. + +Metrics: + +- recall relevance for coding queries +- answer correctness with retrieved memory +- token cost per session +- duplicate item rate +- secret redaction false negatives +- extraction latency + +## Rollout Plan + +### Phase 1: Core Plugin + +- Add `memind-plugin-rawdata-agent`. +- Add `AgentTimelineContent`. +- Add registrar and processor. +- Implement redaction. +- Implement episode chunker. +- Implement deterministic TOOL extraction baseline. +- Implement conservative deterministic RESOLUTION candidates only when the episode contains an explicit failure signal, a later successful validation signal, and evidence tying the fix or conclusion to the outcome. +- Register starter in server. +- Add `tools` default insight type and migration-safe store initialization coverage. +- Add tests. + +### Phase 2: LLM Agent Item Extraction + +- Add `AgentItemExtractionStrategy`. +- Add structured prompt/response for TOOL, RESOLUTION, PLAYBOOK, DIRECTIVE. +- Add playbook gating. +- Add core-compatible entity hints and causal item hints. +- Add tests with mocked LLM. + +### Phase 3: Claude Code and Codex Ingestion + +- Add optional tool-event hooks. +- Add timeline buffer/spool. +- Submit timeline on Stop/PreCompact/SessionEnd. +- Keep fail-open behavior. +- Add config flags and docs. + +### Phase 4: Retrieval Formatting + +- Update Claude/Codex retrieval formatting to group AGENT memories. +- Add category-aware display. +- Add examples and troubleshooting docs. + +### Phase 5: Evaluation and Tuning + +- Tune Insight Tree grouping for playbooks/resolutions. +- Add coding-agent benchmark fixtures. + +### Phase 6: Optional SkillDoc Input + +- Add `agent_skilldoc` if importing existing `SKILL.md` and team playbooks becomes a priority. + +## Acceptance Criteria + +1. Memind server can ingest `rawContent.type = "agent_timeline"` through the normal extraction endpoint. +2. The plugin is loaded through Spring Boot starter and `RawDataPlugin`, not through special-case server code. +3. A successful coding timeline produces AGENT memory items for TOOL. +4. A coding timeline with explicit failure, fix/conclusion, and later validation evidence produces AGENT RESOLUTION memory items. +5. A successful, sufficiently complex coding timeline can produce PLAYBOOK. +6. DIRECTIVE extraction is possible but conservative. +7. No USER categories are emitted by `rawdata-agent`. +8. Exact duplicate complete timeline window submissions do not create duplicate durable memory items. +9. Secret-like values are redacted before RawData persistence, vectorization, segment text, and item extraction. +10. Existing conversation/document/image/audio/toolcall extraction remains compatible. +11. Claude Code and Codex integrations can enable tool/timeline capture without breaking current transcript ingestion. +12. Retrieved AGENT memories are formatted so coding agents can use playbooks, resolutions, tool notes, and directives directly. +13. TOOL memories can participate in AGENT insight building through the `tools` insight type. +14. The plugin works with `MapRawContent` clients before typed client models are released when the server-side `AgentRawContentTypeRegistrar` is loaded. +15. Each extracted item preserves source evidence metadata: `sourceClient`, `sessionId`, `timelineId`, `episodeId`, and `evidenceEventIds`. +16. Default metadata and RawData do not persist absolute local project roots unless `projectRootRaw` capture is explicitly enabled. + +## Open Design Decisions + +### First-Class AgentEpisode Projection + +Recommendation: do not add in v1. + +Use RawData + `agent_episode` segment metadata first. Add a first-class projection later only if Memind needs UI browsing, citation, replay, manual curation, or timeline-detail retrieval equivalent to claude-mem. + +### `rawdata-toolcall` Internal Adapter + +Recommendation: defer. + +Keep `rawdata-toolcall` working as-is for compatibility. After `rawdata-agent` is stable, adapt tool-call-only records into `agent_timeline` internally so both paths share the same TOOL extraction and insight mapping. + +### Dedicated `/open/v1/agent/timeline` Endpoint + +Recommendation: not required for v1. + +Use `memory/extract` with `rawContent.type = "agent_timeline"` first. Add endpoint later as a convenience wrapper if client ergonomics demand it. + +## Risks and Mitigations + +### Risk: Too Much Noise + +Mitigation: + +- Extract on episode end, not every event. +- Require success for playbooks. +- Use confidence thresholds. +- Use deduplication and source episode metadata. + +### Risk: Token Cost + +Mitigation: + +- Deterministic compact segment formatter. +- Truncate large tool outputs. +- Rule-based TOOL extraction. +- LLM extraction only for episode summaries/playbooks. + +### Risk: Secret Leakage + +Mitigation: + +- Redact before formatting segments. +- Avoid file-content capture by default. +- Add tests with common secret formats. +- Preserve redaction metadata. + +### Risk: Host-Specific Complexity Leaks Into Core + +Mitigation: + +- Keep Claude/Codex/OpenClaw/Hermes conversion in integrations. +- Plugin accepts normalized schema only. +- Core only sees RawContent/Segment/MemoryItem. + +### Risk: Duplicates From Hook Retries + +Mitigation: + +- Stable event IDs. +- Deterministic episode IDs. +- Existing MemoryItem deduplication. +- Retry spool only marks submitted after success. + +## Final Recommendation + +Implement `memind-plugin-rawdata-agent` as the canonical agent work-process input plugin. Keep the first version focused on `agent_timeline`, episode chunking, AGENT item extraction, privacy, idempotency, `tools` insight support, and Claude/Codex ingestion. Do not add a first-class Observation model yet; represent observation-like evidence as `agent_episode` segments and metadata. + +This design gives Memind a coding-agent memory path comparable to agentmemory and claude-mem while preserving Memind's strongest advantage: a general, extensible memory kernel that can evolve agent experience into structured long-term understanding. diff --git a/memind-clients/java/README.md b/memind-clients/java/README.md index 825b9846..f728a3b6 100644 --- a/memind-clients/java/README.md +++ b/memind-clients/java/README.md @@ -21,11 +21,12 @@ try (MemindClient client = MemindClient.builder().baseUrl("http://localhost:8366 Map.of( "sourceClient", "claude-code", "sessionId", "session-123", + "agentTurnId", "session-123-agent-turn-1-1", "timelineId", "session-123-agent-1-2", "events", List.of( Map.of( - "id", "event-id", + "eventId", "event-id", "seq", 1, "kind", "command", "toolName", "Bash", diff --git a/memind-clients/python/README.md b/memind-clients/python/README.md index c7ca28fb..1a391f88 100644 --- a/memind-clients/python/README.md +++ b/memind-clients/python/README.md @@ -49,10 +49,11 @@ response = client.memory.extract_agent_timeline( timeline={ "sourceClient": "claude-code", "sessionId": "session-123", + "agentTurnId": "session-123-agent-turn-1-1", "timelineId": "session-123-agent-1-2", "events": [ { - "id": "event-id", + "eventId": "event-id", "seq": 1, "kind": "command", "toolName": "Bash", diff --git a/memind-clients/python/tests/test_client.py b/memind-clients/python/tests/test_client.py index 566e0098..bf8f16cd 100644 --- a/memind-clients/python/tests/test_client.py +++ b/memind-clients/python/tests/test_client.py @@ -111,6 +111,7 @@ def test_extract_agent_timeline_sends_map_raw_content(httpx_mock) -> None: timeline={ "sourceClient": "claude-code", "sessionId": "s", + "agentTurnId": "s-agent-turn-1-1", "timelineId": "t", "events": [], }, diff --git a/memind-clients/typescript/README.md b/memind-clients/typescript/README.md index 24828abe..6f29ca91 100644 --- a/memind-clients/typescript/README.md +++ b/memind-clients/typescript/README.md @@ -85,10 +85,11 @@ const timeline: AgentTimelineContent = { type: 'agent_timeline', sourceClient: 'claude-code', sessionId: 'session-123', + agentTurnId: 'session-123-agent-turn-1-1', timelineId: 'session-123-agent-1-2', events: [ { - id: 'event-id', + eventId: 'event-id', seq: 1, kind: 'command', toolName: 'Bash', diff --git a/memind-clients/typescript/src/types/message.ts b/memind-clients/typescript/src/types/message.ts index 710a6e5f..17051dcc 100644 --- a/memind-clients/typescript/src/types/message.ts +++ b/memind-clients/typescript/src/types/message.ts @@ -77,7 +77,7 @@ export type JsonObjectRawContent = { } export type AgentTimelineEvent = { - id?: string + eventId?: string seq?: number kind?: string occurredAt?: string @@ -99,6 +99,7 @@ export type AgentTimelineContent = { sourceClient?: string sourceVersion?: string sessionId: string + agentTurnId: string timelineId: string events: AgentTimelineEvent[] project?: Record diff --git a/memind-clients/typescript/tests/client.test.ts b/memind-clients/typescript/tests/client.test.ts index 8fed0b5b..5c6af964 100644 --- a/memind-clients/typescript/tests/client.test.ts +++ b/memind-clients/typescript/tests/client.test.ts @@ -207,6 +207,7 @@ describe('MemindClient', () => { type: 'agent_timeline', sourceClient: 'claude-code', sessionId: 's', + agentTurnId: 's-agent-turn-1-1', timelineId: 't', events: [], } satisfies AgentTimelineContent @@ -223,6 +224,7 @@ describe('MemindClient', () => { type: 'agent_timeline', sourceClient: 'claude-code', sessionId: 's', + agentTurnId: 's-agent-turn-1-1', timelineId: 't', events: [], }) diff --git a/memind-integrations/claude-code/README.md b/memind-integrations/claude-code/README.md index f31156f2..344dd982 100644 --- a/memind-integrations/claude-code/README.md +++ b/memind-integrations/claude-code/README.md @@ -273,11 +273,12 @@ extraction path. A typical timeline payload looks like: "type": "agent_timeline", "sourceClient": "claude-code", "sessionId": "session-123", + "agentTurnId": "session-123-agent-turn-1-1", "timelineId": "session-123-agent-1-2", "project": {"name": "payment-service", "rootPath": "/repo/payment-service"}, "events": [ { - "id": "event-id", + "eventId": "event-id", "seq": 1, "kind": "command", "toolName": "Bash", diff --git a/memind-integrations/claude-code/scripts/ingest.py b/memind-integrations/claude-code/scripts/ingest.py index 3428e00b..4028bdf2 100644 --- a/memind-integrations/claude-code/scripts/ingest.py +++ b/memind-integrations/claude-code/scripts/ingest.py @@ -82,7 +82,7 @@ def _spool_agent_timeline(retry_spool, identity, source_client, session_id, even "agentId": identity["agentId"], "sourceClient": source_client, "sessionId": session_id, - "eventIds": [event["id"] for event in events if event.get("id")], + "eventIds": [event["eventId"] for event in events if event.get("eventId")], "rawContent": raw_content, } ) @@ -150,7 +150,9 @@ async def ingest_messages_async(config, hook_input, commit=False, max_messages=N else: status = getattr(response, "status", None) if status == "SUCCESS": - state.clear_agent_events([event["id"] for event in agent_events if event.get("id")]) + state.clear_agent_events( + [event["eventId"] for event in agent_events if event.get("eventId")] + ) else: _spool_agent_timeline( retry_spool, diff --git a/memind-integrations/claude-code/scripts/lib/agent_timeline.py b/memind-integrations/claude-code/scripts/lib/agent_timeline.py index 5bdb868f..f16d719c 100644 --- a/memind-integrations/claude-code/scripts/lib/agent_timeline.py +++ b/memind-integrations/claude-code/scripts/lib/agent_timeline.py @@ -99,7 +99,7 @@ def normalize_hook_event(hook_input, seq): redaction_kinds = [] event = { - "id": event_id(source_client, session_id, seq, hook_input), + "eventId": event_id(source_client, session_id, seq, hook_input), "seq": seq, "kind": _event_kind(tool_name), "occurredAt": hook_input.get("timestamp"), @@ -155,15 +155,18 @@ def build_timeline_payload(config, identity, session_id, events, hook_input): cwd = hook_input.get("cwd") first_seq = events[0].get("seq") if events else 0 last_seq = events[-1].get("seq") if events else 0 + agent_turn_id = f"{session_id}-agent-turn-{first_seq}-{last_seq}" payload = { "type": "agent_timeline", "sourceClient": source_client, "sessionId": session_id, + "agentTurnId": agent_turn_id, "timelineId": f"{session_id}-agent-{first_seq}-{last_seq}", "events": list(events), "metadata": { "userId": identity.get("userId"), "agentId": identity.get("agentId"), + "eventIds": [event["eventId"] for event in events if event.get("eventId")], }, } if cwd: diff --git a/memind-integrations/claude-code/scripts/lib/state.py b/memind-integrations/claude-code/scripts/lib/state.py index 4c8533a6..e974d9b3 100644 --- a/memind-integrations/claude-code/scripts/lib/state.py +++ b/memind-integrations/claude-code/scripts/lib/state.py @@ -45,8 +45,8 @@ def mark_submitted(self, fingerprints): def append_agent_event(self, event): events = list(self.data.get("agentEvents", [])) - event_id = event.get("id") - if event_id and any(existing.get("id") == event_id for existing in events): + event_id = event.get("eventId") + if event_id and any(existing.get("eventId") == event_id for existing in events): return events.append(event) if len(events) > MAX_AGENT_EVENTS: @@ -63,7 +63,9 @@ def clear_agent_events(self, event_ids): if not event_ids: return self.data["agentEvents"] = [ - event for event in self.data.get("agentEvents", []) if event.get("id") not in event_ids + event + for event in self.data.get("agentEvents", []) + if event.get("eventId") not in event_ids ] self.data["updatedAt"] = time.time() diff --git a/memind-integrations/claude-code/tests/test_agent_timeline.py b/memind-integrations/claude-code/tests/test_agent_timeline.py index 5d8d93f1..874c53e5 100644 --- a/memind-integrations/claude-code/tests/test_agent_timeline.py +++ b/memind-integrations/claude-code/tests/test_agent_timeline.py @@ -33,6 +33,8 @@ def test_normalizes_post_tool_use_to_command_event(self): ) self.assertEqual(event["kind"], "command") + self.assertIn("eventId", event) + self.assertNotIn("id", event) self.assertEqual(event["seq"], 1) self.assertEqual(event["command"], "npm test payment") self.assertEqual(event["status"], "failed") @@ -85,7 +87,9 @@ def test_builds_agent_timeline_payload(self): self.assertEqual(payload["type"], "agent_timeline") self.assertEqual(payload["sourceClient"], "claude-code") self.assertEqual(payload["sessionId"], "s") + self.assertEqual(payload["agentTurnId"], "s-agent-turn-1-1") self.assertEqual(payload["timelineId"], "s-agent-1-1") + self.assertIn("eventId", payload["events"][0]) self.assertEqual(payload["events"][0]["seq"], 1) self.assertEqual(payload["project"]["name"], "project") self.assertEqual(payload["project"]["rootPath"], "/tmp/project") diff --git a/memind-integrations/claude-code/tests/test_hooks.py b/memind-integrations/claude-code/tests/test_hooks.py index 50d25f78..155e6f88 100644 --- a/memind-integrations/claude-code/tests/test_hooks.py +++ b/memind-integrations/claude-code/tests/test_hooks.py @@ -337,7 +337,7 @@ def test_ingest_flushes_agent_timeline_and_clears_events_on_success(self): state_dir = Path(tmp) / "state" with SessionStateStore(state_dir).locked("s1") as state: state.append_agent_event( - {"id": "e1", "seq": 1, "kind": "command", "command": "npm test"} + {"eventId": "e1", "seq": 1, "kind": "command", "command": "npm test"} ) with mock.patch.object(ingest, "state_root", return_value=state_dir): with mock.patch.object(ingest, "retry_root", return_value=Path(tmp) / "retry"): @@ -356,7 +356,7 @@ def test_ingest_flushes_agent_timeline_and_clears_events_on_success(self): raw_content = client.extract.await_args.args[2] self.assertEqual(raw_content["type"], "agent_timeline") self.assertEqual(raw_content["sessionId"], "s1") - self.assertEqual(raw_content["events"][0]["id"], "e1") + self.assertEqual(raw_content["events"][0]["eventId"], "e1") with SessionStateStore(state_dir).locked("s1") as state: self.assertEqual(state.agent_events(), []) @@ -381,7 +381,7 @@ def test_ingest_spools_agent_timeline_on_partial_success(self): retry_dir = Path(tmp) / "retry" with SessionStateStore(state_dir).locked("s1") as state: state.append_agent_event( - {"id": "e1", "seq": 1, "kind": "command", "command": "npm test"} + {"eventId": "e1", "seq": 1, "kind": "command", "command": "npm test"} ) with mock.patch.object(ingest, "state_root", return_value=state_dir): with mock.patch.object(ingest, "retry_root", return_value=retry_dir): @@ -401,7 +401,7 @@ def test_ingest_spools_agent_timeline_on_partial_success(self): self.assertEqual(payload["kind"], "extract") self.assertEqual(payload["eventIds"], ["e1"]) self.assertEqual(payload["rawContent"]["type"], "agent_timeline") - self.assertEqual(payload["rawContent"]["events"][0]["id"], "e1") + self.assertEqual(payload["rawContent"]["events"][0]["eventId"], "e1") with SessionStateStore(state_dir).locked("s1") as state: self.assertEqual(len(state.agent_events()), 1) @@ -513,8 +513,8 @@ def test_session_start_replays_agent_timeline_payload_and_clears_event_ids(self) retry_dir = Path(tmp) / "retry" state_dir = Path(tmp) / "state" with SessionStateStore(state_dir).locked("s1") as state: - state.append_agent_event({"id": "e1", "seq": 1, "kind": "command"}) - state.append_agent_event({"id": "e2", "seq": 2, "kind": "tool_result"}) + state.append_agent_event({"eventId": "e1", "seq": 1, "kind": "command"}) + state.append_agent_event({"eventId": "e2", "seq": 2, "kind": "tool_result"}) RetrySpool(retry_dir).enqueue( { "kind": "extract", @@ -528,7 +528,7 @@ def test_session_start_replays_agent_timeline_payload_and_clears_event_ids(self) "sourceClient": "claude-code", "sessionId": "s1", "timelineId": "s1-agent", - "events": [{"id": "e1", "seq": 1, "kind": "command"}], + "events": [{"eventId": "e1", "seq": 1, "kind": "command"}], }, } ) @@ -551,7 +551,10 @@ def test_session_start_replays_agent_timeline_payload_and_clears_event_ids(self) client.commit = mock.AsyncMock(return_value=None) session_start.main() with SessionStateStore(state_dir).locked("s1") as state: - self.assertEqual(state.agent_events(), [{"id": "e2", "seq": 2, "kind": "tool_result"}]) + self.assertEqual( + state.agent_events(), + [{"eventId": "e2", "seq": 2, "kind": "tool_result"}], + ) self.assertEqual(list(retry_dir.glob("*.json")), []) client.extract.assert_awaited_once() diff --git a/memind-integrations/claude-code/tests/test_state.py b/memind-integrations/claude-code/tests/test_state.py index 18a49b3b..ab7c7051 100644 --- a/memind-integrations/claude-code/tests/test_state.py +++ b/memind-integrations/claude-code/tests/test_state.py @@ -44,29 +44,29 @@ def test_cleanup_removes_old_state(self): self.assertEqual(removed, 1) self.assertFalse(old_file.exists()) - def test_agent_events_are_deduplicated_and_clear_by_id(self): + def test_agent_events_are_deduplicated_and_clear_by_event_id(self): with tempfile.TemporaryDirectory() as tmp: store = SessionStateStore(Path(tmp)) with store.locked("session-1") as state: self.assertEqual(state.next_agent_seq(), 1) self.assertEqual(state.next_agent_seq(), 2) - state.append_agent_event({"id": "e1", "seq": 1}) - state.append_agent_event({"id": "e1", "seq": 1}) - state.append_agent_event({"id": "e2", "seq": 2}) + state.append_agent_event({"eventId": "e1", "seq": 1}) + state.append_agent_event({"eventId": "e1", "seq": 1}) + state.append_agent_event({"eventId": "e2", "seq": 2}) state.clear_agent_events(["e1"]) with store.locked("session-1") as state: - self.assertEqual(state.agent_events(), [{"id": "e2", "seq": 2}]) + self.assertEqual(state.agent_events(), [{"eventId": "e2", "seq": 2}]) def test_agent_event_buffer_has_soft_cap(self): with tempfile.TemporaryDirectory() as tmp: store = SessionStateStore(Path(tmp)) with store.locked("session-1") as state: for index in range(501): - state.append_agent_event({"id": f"e{index}", "seq": index}) + state.append_agent_event({"eventId": f"e{index}", "seq": index}) with store.locked("session-1") as state: events = state.agent_events() self.assertEqual(len(events), 500) - self.assertEqual(events[0]["id"], "e1") + self.assertEqual(events[0]["eventId"], "e1") self.assertTrue(state.data["agentEventsTruncated"]) diff --git a/memind-integrations/codex/README.md b/memind-integrations/codex/README.md index 92ab4855..f3e35ae1 100644 --- a/memind-integrations/codex/README.md +++ b/memind-integrations/codex/README.md @@ -281,11 +281,12 @@ path. A typical timeline payload looks like: "type": "agent_timeline", "sourceClient": "codex", "sessionId": "session-123", + "agentTurnId": "session-123-agent-turn-1-1", "timelineId": "session-123-agent-1-2", "project": {"name": "payment-service", "rootPath": "/repo/payment-service"}, "events": [ { - "id": "event-id", + "eventId": "event-id", "seq": 1, "kind": "command", "toolName": "Bash", diff --git a/memind-integrations/codex/scripts/ingest.py b/memind-integrations/codex/scripts/ingest.py index 984cc4b5..578e88f7 100644 --- a/memind-integrations/codex/scripts/ingest.py +++ b/memind-integrations/codex/scripts/ingest.py @@ -83,7 +83,7 @@ def _spool_agent_timeline(retry_spool, identity, source_client, session_key, eve "agentId": identity["agentId"], "sourceClient": source_client, "sessionKey": session_key, - "eventIds": [event["id"] for event in events if event.get("id")], + "eventIds": [event["eventId"] for event in events if event.get("eventId")], "rawContent": raw_content, } ) @@ -154,7 +154,8 @@ async def ingest_messages_async(config, hook_input): status = getattr(response, "status", None) if status == "SUCCESS": store.clear_agent_events( - session_key, [event["id"] for event in agent_events if event.get("id")] + session_key, + [event["eventId"] for event in agent_events if event.get("eventId")], ) else: _spool_agent_timeline( diff --git a/memind-integrations/codex/scripts/lib/agent_timeline.py b/memind-integrations/codex/scripts/lib/agent_timeline.py index 7df0d496..d5fb1b92 100644 --- a/memind-integrations/codex/scripts/lib/agent_timeline.py +++ b/memind-integrations/codex/scripts/lib/agent_timeline.py @@ -99,7 +99,7 @@ def normalize_hook_event(hook_input, seq): redaction_kinds = [] event = { - "id": event_id(source_client, session_id, seq, hook_input), + "eventId": event_id(source_client, session_id, seq, hook_input), "seq": seq, "kind": _event_kind(tool_name), "occurredAt": hook_input.get("timestamp"), @@ -153,15 +153,18 @@ def build_timeline_payload(config, identity, session_id, events, hook_input): cwd = hook_input.get("cwd") first_seq = events[0].get("seq") if events else 0 last_seq = events[-1].get("seq") if events else 0 + agent_turn_id = f"{session_id}-agent-turn-{first_seq}-{last_seq}" payload = { "type": "agent_timeline", "sourceClient": source_client, "sessionId": session_id, + "agentTurnId": agent_turn_id, "timelineId": f"{session_id}-agent-{first_seq}-{last_seq}", "events": list(events), "metadata": { "userId": identity.get("userId"), "agentId": identity.get("agentId"), + "eventIds": [event["eventId"] for event in events if event.get("eventId")], }, } if cwd: diff --git a/memind-integrations/codex/scripts/lib/state.py b/memind-integrations/codex/scripts/lib/state.py index c9f25ff5..7156a1a2 100644 --- a/memind-integrations/codex/scripts/lib/state.py +++ b/memind-integrations/codex/scripts/lib/state.py @@ -104,8 +104,8 @@ def mark_submitted(self, fingerprints): def append_agent_event(self, event): events = list(self.data.get("agentEvents", [])) - event_id = event.get("id") - if event_id and any(existing.get("id") == event_id for existing in events): + event_id = event.get("eventId") + if event_id and any(existing.get("eventId") == event_id for existing in events): return events.append(event) if len(events) > MAX_AGENT_EVENTS: @@ -122,7 +122,9 @@ def clear_agent_events(self, event_ids): if not event_ids: return self.data["agentEvents"] = [ - event for event in self.data.get("agentEvents", []) if event.get("id") not in event_ids + event + for event in self.data.get("agentEvents", []) + if event.get("eventId") not in event_ids ] self.data["updatedAt"] = time.time() diff --git a/memind-integrations/codex/tests/test_agent_timeline.py b/memind-integrations/codex/tests/test_agent_timeline.py index d86c8e50..8a7c7336 100644 --- a/memind-integrations/codex/tests/test_agent_timeline.py +++ b/memind-integrations/codex/tests/test_agent_timeline.py @@ -34,6 +34,8 @@ def test_normalizes_post_tool_use_to_command_event(self): ) self.assertEqual(event["kind"], "command") + self.assertIn("eventId", event) + self.assertNotIn("id", event) self.assertEqual(event["seq"], 1) self.assertEqual(event["command"], "cargo test payment") self.assertEqual(event["status"], "failed") @@ -82,7 +84,9 @@ def test_builds_agent_timeline_payload(self): self.assertEqual(payload["type"], "agent_timeline") self.assertEqual(payload["sourceClient"], "codex") self.assertEqual(payload["sessionId"], "s") + self.assertEqual(payload["agentTurnId"], "s-agent-turn-1-1") self.assertEqual(payload["timelineId"], "s-agent-1-1") + self.assertIn("eventId", payload["events"][0]) self.assertEqual(payload["events"][0]["seq"], 1) self.assertEqual(payload["project"]["name"], "project") self.assertEqual(payload["project"]["rootPath"], "/tmp/project") diff --git a/memind-integrations/codex/tests/test_hooks.py b/memind-integrations/codex/tests/test_hooks.py index c04e23e4..c47ee1f3 100644 --- a/memind-integrations/codex/tests/test_hooks.py +++ b/memind-integrations/codex/tests/test_hooks.py @@ -374,7 +374,7 @@ def test_ingest_flushes_agent_timeline_and_clears_events_on_success(self): state_root = Path(tmp) / "state" with SessionStateStore(state_root).locked("s1") as state: state.append_agent_event( - {"id": "e1", "seq": 1, "kind": "command", "command": "cargo test"} + {"eventId": "e1", "seq": 1, "kind": "command", "command": "cargo test"} ) with mock.patch.object(ingest, "state_root", return_value=state_root): with mock.patch.object(ingest, "retry_root", return_value=Path(tmp) / "retry"): @@ -387,7 +387,7 @@ def test_ingest_flushes_agent_timeline_and_clears_events_on_success(self): raw_content = client.extract.await_args.args[2] self.assertEqual(raw_content["type"], "agent_timeline") self.assertEqual(raw_content["sessionId"], "s1") - self.assertEqual(raw_content["events"][0]["id"], "e1") + self.assertEqual(raw_content["events"][0]["eventId"], "e1") with SessionStateStore(state_root).locked("s1") as state: self.assertEqual(state.agent_events(), []) @@ -412,7 +412,7 @@ def test_ingest_spools_agent_timeline_on_partial_success(self): retry_root = Path(tmp) / "retry" with SessionStateStore(state_root).locked("s1") as state: state.append_agent_event( - {"id": "e1", "seq": 1, "kind": "command", "command": "cargo test"} + {"eventId": "e1", "seq": 1, "kind": "command", "command": "cargo test"} ) with mock.patch.object(ingest, "state_root", return_value=state_root): with mock.patch.object(ingest, "retry_root", return_value=retry_root): @@ -427,7 +427,7 @@ def test_ingest_spools_agent_timeline_on_partial_success(self): self.assertEqual(payload["sessionKey"], "s1") self.assertEqual(payload["eventIds"], ["e1"]) self.assertEqual(payload["rawContent"]["type"], "agent_timeline") - self.assertEqual(payload["rawContent"]["events"][0]["id"], "e1") + self.assertEqual(payload["rawContent"]["events"][0]["eventId"], "e1") with SessionStateStore(state_root).locked("s1") as state: self.assertEqual(len(state.agent_events()), 1) @@ -601,8 +601,8 @@ def test_session_start_replays_agent_timeline_payload_and_clears_event_ids(self) retry_root = Path(tmp) / "retry" state_root = Path(tmp) / "state" with SessionStateStore(state_root).locked("s1") as state: - state.append_agent_event({"id": "e1", "seq": 1, "kind": "command"}) - state.append_agent_event({"id": "e2", "seq": 2, "kind": "tool_result"}) + state.append_agent_event({"eventId": "e1", "seq": 1, "kind": "command"}) + state.append_agent_event({"eventId": "e2", "seq": 2, "kind": "tool_result"}) RetrySpool(retry_root).enqueue( { "kind": "extract", @@ -616,7 +616,7 @@ def test_session_start_replays_agent_timeline_payload_and_clears_event_ids(self) "sourceClient": "codex", "sessionId": "s1", "timelineId": "s1-agent-1-1", - "events": [{"id": "e1", "seq": 1, "kind": "command"}], + "events": [{"eventId": "e1", "seq": 1, "kind": "command"}], }, } ) @@ -638,7 +638,10 @@ def test_session_start_replays_agent_timeline_payload_and_clears_event_ids(self) client.commit = mock.AsyncMock(return_value=None) session_start.run_session_start(config) with SessionStateStore(state_root).locked("s1") as state: - self.assertEqual(state.agent_events(), [{"id": "e2", "seq": 2, "kind": "tool_result"}]) + self.assertEqual( + state.agent_events(), + [{"eventId": "e2", "seq": 2, "kind": "tool_result"}], + ) self.assertEqual(list(retry_root.glob("*.json")), []) client.extract.assert_awaited_once() diff --git a/memind-integrations/codex/tests/test_state.py b/memind-integrations/codex/tests/test_state.py index 589c5063..4779cc53 100644 --- a/memind-integrations/codex/tests/test_state.py +++ b/memind-integrations/codex/tests/test_state.py @@ -45,29 +45,29 @@ def test_mark_submitted_persists_immediately(self): with store.locked("session-1") as state: self.assertTrue(state.is_submitted("a")) - def test_agent_events_are_deduplicated_and_clear_by_id(self): + def test_agent_events_are_deduplicated_and_clear_by_event_id(self): with tempfile.TemporaryDirectory() as tmp: store = SessionStateStore(Path(tmp)) with store.locked("session-1") as state: self.assertEqual(state.next_agent_seq(), 1) self.assertEqual(state.next_agent_seq(), 2) - state.append_agent_event({"id": "e1", "seq": 1}) - state.append_agent_event({"id": "e1", "seq": 1}) - state.append_agent_event({"id": "e2", "seq": 2}) + state.append_agent_event({"eventId": "e1", "seq": 1}) + state.append_agent_event({"eventId": "e1", "seq": 1}) + state.append_agent_event({"eventId": "e2", "seq": 2}) state.clear_agent_events(["e1"]) with store.locked("session-1") as state: - self.assertEqual(state.agent_events(), [{"id": "e2", "seq": 2}]) + self.assertEqual(state.agent_events(), [{"eventId": "e2", "seq": 2}]) def test_agent_event_buffer_has_soft_cap(self): with tempfile.TemporaryDirectory() as tmp: store = SessionStateStore(Path(tmp)) with store.locked("session-1") as state: for index in range(501): - state.append_agent_event({"id": f"e{index}", "seq": index}) + state.append_agent_event({"eventId": f"e{index}", "seq": index}) with store.locked("session-1") as state: events = state.agent_events() self.assertEqual(len(events), 500) - self.assertEqual(events[0]["id"], "e1") + self.assertEqual(events[0]["eventId"], "e1") self.assertTrue(state.data["agentEventsTruncated"]) def test_cleanup_removes_old_state(self): diff --git a/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-core/src/test/java/com/openmemind/ai/memory/plugin/jdbc/internal/support/JsonCodecTest.java b/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-core/src/test/java/com/openmemind/ai/memory/plugin/jdbc/internal/support/JsonCodecTest.java index 8bbb1579..593e3c1a 100644 --- a/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-core/src/test/java/com/openmemind/ai/memory/plugin/jdbc/internal/support/JsonCodecTest.java +++ b/memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-core/src/test/java/com/openmemind/ai/memory/plugin/jdbc/internal/support/JsonCodecTest.java @@ -140,6 +140,7 @@ private static AgentTimelineContent sampleAgentTimelineContent() { "claude-code", "1.0", "session-1", + "session-1-agent-turn-1-2", "timeline-1", new AgentProject("memind", "/repo/memind", null, Map.of()), List.of( diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java index a2515b31..94f1e141 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java @@ -117,7 +117,8 @@ private AgentEpisode buildEpisode( String phase, Map extraMetadata) { List events = sorted(rawEvents); - List eventIds = events.stream().map(AgentEvent::id).filter(this::hasText).toList(); + List eventIds = + events.stream().map(AgentEvent::eventId).filter(this::hasText).toList(); List commandEvents = commandEvents(events); List fileReferences = fileReferences(events); List toolCalls = toolCalls(events); @@ -217,7 +218,7 @@ private List commandEvents(List events) { event.output(), event.exitCode(), event.seq(), - event.id())) + event.eventId())) .toList(); } @@ -227,7 +228,10 @@ private List fileReferences(List events) { .map( event -> new AgentFileReference( - event.path(), event.operation(), event.seq(), event.id())) + event.path(), + event.operation(), + event.seq(), + event.eventId())) .toList(); } @@ -237,7 +241,10 @@ private List toolCalls(List events) { .map( event -> new AgentToolCall( - event.toolName(), event.status(), event.seq(), event.id())) + event.toolName(), + event.status(), + event.seq(), + event.eventId())) .toList(); } @@ -401,7 +408,8 @@ private List sorted(List events) { AgentEvent::occurredAt, Comparator.nullsLast(Instant::compareTo)) .thenComparing( - AgentEvent::id, Comparator.nullsLast(String::compareTo))) + AgentEvent::eventId, + Comparator.nullsLast(String::compareTo))) .toList(); } diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java index 58d8987c..8c1384af 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java @@ -58,7 +58,8 @@ private String formatContent(AgentTimelineContent timeline, AgentEpisode episode actions.forEach(action -> lines.add("- " + action)); } lines.add("Evidence:"); - episode.events().forEach(event -> lines.add("- " + event.id() + ": " + evidence(event))); + episode.events() + .forEach(event -> lines.add("- " + event.eventId() + ": " + evidence(event))); return String.join("\n", lines); } @@ -67,6 +68,7 @@ private Map metadata(AgentTimelineContent timeline, AgentEpisode metadata.put("segmentType", "agent_episode"); metadata.put("sourceClient", timeline.sourceClient()); metadata.put("sessionId", timeline.sessionId()); + metadata.put("agentTurnId", timeline.agentTurnId()); metadata.put("timelineId", timeline.timelineId()); metadata.put("episodeId", episode.id()); metadata.put("phase", episode.phase()); diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentTimelineChunker.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentTimelineChunker.java index d84a5fe1..1cb7a4ea 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentTimelineChunker.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentTimelineChunker.java @@ -62,6 +62,7 @@ public List chunk(AgentTimelineContent content) { content.sourceClient(), content.sourceVersion(), content.sessionId(), + content.agentTurnId(), content.timelineId(), content.project(), content.events().stream().map(redactor::redact).toList(), diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java index 9d424ded..4ae2324a 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java @@ -41,6 +41,7 @@ public final class AgentTimelineContent extends RawContent { private final String sourceClient; private final String sourceVersion; private final String sessionId; + private final String agentTurnId; private final String timelineId; private final AgentProject project; private final List events; @@ -50,10 +51,19 @@ public AgentTimelineContent( String sourceClient, String sourceVersion, String sessionId, + String agentTurnId, String timelineId, AgentProject project, List events) { - this(sourceClient, sourceVersion, sessionId, timelineId, project, events, Map.of()); + this( + sourceClient, + sourceVersion, + sessionId, + agentTurnId, + timelineId, + project, + events, + Map.of()); } @JsonCreator @@ -61,6 +71,7 @@ public AgentTimelineContent( @JsonProperty("sourceClient") String sourceClient, @JsonProperty("sourceVersion") String sourceVersion, @JsonProperty("sessionId") String sessionId, + @JsonProperty("agentTurnId") String agentTurnId, @JsonProperty("timelineId") String timelineId, @JsonProperty("project") AgentProject project, @JsonProperty("events") List events, @@ -68,6 +79,7 @@ public AgentTimelineContent( this.sourceClient = sourceClient; this.sourceVersion = sourceVersion; this.sessionId = sessionId; + this.agentTurnId = agentTurnId; this.timelineId = timelineId; this.project = project; this.events = sortedEvents(events); @@ -85,6 +97,7 @@ public String toContentString() { lines.add("Agent Timeline"); append(lines, "Source", sourceClientWithVersion()); append(lines, "Session", sessionId); + append(lines, "Agent Turn", agentTurnId); append(lines, "Timeline", timelineId); if (project != null) { append(lines, "Project", project.toDisplayString()); @@ -99,7 +112,7 @@ public String toContentString() { @Override public String getContentId() { - String eventIds = events.stream().map(AgentEvent::id).collect(Collectors.joining(",")); + String eventIds = events.stream().map(AgentEvent::eventId).collect(Collectors.joining(",")); String eventHash = HashUtils.sampledSha256( events.stream().map(this::canonicalEvent).collect(Collectors.joining("|"))); @@ -108,6 +121,7 @@ public String getContentId() { "|", normalized(sourceClient), normalized(sessionId), + normalized(agentTurnId), normalized(timelineId), eventIds, normalized(eventHash))); @@ -121,7 +135,14 @@ public Map contentMetadata() { @Override public RawContent withMetadata(Map metadata) { return new AgentTimelineContent( - sourceClient, sourceVersion, sessionId, timelineId, project, events, metadata); + sourceClient, + sourceVersion, + sessionId, + agentTurnId, + timelineId, + project, + events, + metadata); } @JsonProperty("sourceClient") @@ -139,6 +160,11 @@ public String sessionId() { return sessionId; } + @JsonProperty("agentTurnId") + public String agentTurnId() { + return agentTurnId; + } + @JsonProperty("timelineId") public String timelineId() { return timelineId; @@ -190,7 +216,8 @@ private static List sortedEvents(List events) { AgentEvent::occurredAt, Comparator.nullsLast(Instant::compareTo)) .thenComparing( - AgentEvent::id, Comparator.nullsLast(String::compareTo))) + AgentEvent::eventId, + Comparator.nullsLast(String::compareTo))) .toList(); } @@ -208,8 +235,8 @@ private static String formatEvent(AgentEvent event) { if (event.kind() != null) { parts.add(event.kind().wireValue()); } - if (event.id() != null && !event.id().isBlank()) { - parts.add(event.id()); + if (event.eventId() != null && !event.eventId().isBlank()) { + parts.add(event.eventId()); } if (event.status() != null) { parts.add("[" + event.status().wireValue() + "]"); @@ -238,7 +265,7 @@ private static void appendText(List parts, String value) { private String canonicalEvent(AgentEvent event) { return String.join( "\u001f", - normalized(event.id()), + normalized(event.eventId()), normalized(event.seq()), event.kind() == null ? "" : event.kind().wireValue(), normalized(event.occurredAt()), diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEvent.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEvent.java index 77dabaff..71b3aa65 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEvent.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEvent.java @@ -20,7 +20,7 @@ * One normalized event from an agent session timeline. */ public record AgentEvent( - String id, + String eventId, Integer seq, AgentEventKind kind, Instant occurredAt, diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactor.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactor.java index e96960cd..b1da54d6 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactor.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactor.java @@ -72,7 +72,7 @@ public AgentEvent redact(AgentEvent event) { Map metadata = mergeMetadata(event.metadata(), state); return new AgentEvent( - event.id(), + event.eventId(), event.seq(), event.kind(), event.occurredAt(), diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java index d613c581..3b786f74 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java @@ -216,7 +216,7 @@ private static AgentEvent eventWithMetadata( "npm test payment", 0); return new AgentEvent( - base.id(), + base.eventId(), base.seq(), base.kind(), base.occurredAt(), diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeTestSupport.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeTestSupport.java index bc48bdb5..aad8ba04 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeTestSupport.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeTestSupport.java @@ -31,6 +31,7 @@ static AgentTimelineContent paymentTimeline(List events) { "codex", "1.0", "session-123", + "session-123-agent-turn-1-5", "timeline-123", new AgentProject("payments-api", "/Users/alice/work/payments-api", null, Map.of()), events); diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java index c341c45e..00903b2b 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java @@ -77,16 +77,28 @@ void contentShouldExposeDeterministicIdentityAndReadableTimelineText() { AgentTimelineContent content = new AgentTimelineContent( - "claude-code", "1.0", "session-123", "timeline-123", project, events); + "claude-code", + "1.0", + "session-123", + "session-123-agent-turn-1-2", + "timeline-123", + project, + events); AgentTimelineContent duplicate = new AgentTimelineContent( - "claude-code", "1.0", "session-123", "timeline-123", project, events); + "claude-code", + "1.0", + "session-123", + "session-123-agent-turn-1-2", + "timeline-123", + project, + events); assertThat(content.contentType()).isEqualTo("AGENT_TIMELINE"); assertThat(content.toContentString()) .contains("Goal:", "Fix payment tests", "npm test payment"); assertThat(content.getContentId()).isEqualTo(duplicate.getContentId()); - assertThat(content.events()).extracting(AgentEvent::id).containsExactly("e1", "e2"); + assertThat(content.events()).extracting(AgentEvent::eventId).containsExactly("e1", "e2"); } @Test @@ -96,6 +108,7 @@ void jacksonRoundTripShouldPreserveSubtypeAndUserPromptText() throws Exception { "codex", "1.0", "session-1", + "session-1-agent-turn-1-1", "timeline-1", new AgentProject("memind", "/repo/memind", null, Map.of()), List.of( @@ -120,6 +133,8 @@ void jacksonRoundTripShouldPreserveSubtypeAndUserPromptText() throws Exception { RawContent decoded = OBJECT_MAPPER.readValue(json, RawContent.class); assertThat(json).contains("\"type\":\"agent_timeline\""); + assertThat(json).contains("\"eventId\":\"e1\""); + assertThat(json).doesNotContain("\"id\":\"e1\""); assertThat(decoded).isInstanceOf(AgentTimelineContent.class); assertThat(((AgentTimelineContent) decoded).events()) .singleElement() @@ -127,4 +142,32 @@ void jacksonRoundTripShouldPreserveSubtypeAndUserPromptText() throws Exception { .isEqualTo("Review rawdata-agent design"); assertThat(decoded.toContentString()).contains("Goal: Review rawdata-agent design"); } + + @Test + void jacksonShouldPreserveAgentTurnAndEventIdFields() throws Exception { + String json = + """ + { + "type": "agent_timeline", + "sourceClient": "claude-code", + "sessionId": "session-1", + "agentTurnId": "turn-1", + "timelineId": "timeline-1", + "events": [ + { + "eventId": "event-new", + "seq": 1, + "kind": "user_prompt", + "text": "Fix test" + } + ] + } + """; + + RawContent decoded = OBJECT_MAPPER.readValue(json, RawContent.class); + + AgentTimelineContent timeline = (AgentTimelineContent) decoded; + assertThat(timeline.agentTurnId()).isEqualTo("turn-1"); + assertThat(timeline.events()).extracting(AgentEvent::eventId).containsExactly("event-new"); + } } diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/integration/AgentExtractionPipelineIntegrationTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/integration/AgentExtractionPipelineIntegrationTest.java index bf6881e7..9af53bbf 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/integration/AgentExtractionPipelineIntegrationTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/integration/AgentExtractionPipelineIntegrationTest.java @@ -298,6 +298,7 @@ private static AgentTimelineContent paymentTimeline(List events) { "codex", "1.0", "session-123", + "session-123-agent-turn-1-5", "timeline-123", new AgentProject("payments-api", "/Users/alice/work/payments-api", null, Map.of()), events); diff --git a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/AgentRawDataAutoConfigurationTest.java b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/AgentRawDataAutoConfigurationTest.java index 92392c99..aa1697b5 100644 --- a/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/AgentRawDataAutoConfigurationTest.java +++ b/memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/AgentRawDataAutoConfigurationTest.java @@ -53,6 +53,7 @@ void registersAgentRawDataPluginAndAgentTimelineJsonBinding() { "type": "agent_timeline", "sourceClient": "claude-code", "sessionId": "s", + "agentTurnId": "s-agent-turn-1-1", "timelineId": "t", "events": [] } diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/AgentTimelineOpenApiIntegrationTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/AgentTimelineOpenApiIntegrationTest.java index 6a42587c..82444cbe 100644 --- a/memind-server/src/test/java/com/openmemind/ai/memory/server/AgentTimelineOpenApiIntegrationTest.java +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/AgentTimelineOpenApiIntegrationTest.java @@ -281,6 +281,7 @@ private static String paymentTimelineRequest() { "type": "agent_timeline", "sourceClient": "claude-code", "sessionId": "s", + "agentTurnId": "s-agent-turn-1-5", "timelineId": "t", "project": { "name": "payments-api", @@ -288,14 +289,14 @@ private static String paymentTimelineRequest() { }, "events": [ { - "id": "e1", + "eventId": "e1", "seq": 1, "kind": "user_prompt", "text": "Fix payment tests", "occurredAt": "2026-05-24T10:00:00Z" }, { - "id": "e2", + "eventId": "e2", "seq": 2, "kind": "command", "toolName": "Bash", @@ -306,7 +307,7 @@ private static String paymentTimelineRequest() { "occurredAt": "2026-05-24T10:01:00Z" }, { - "id": "e3", + "eventId": "e3", "seq": 3, "kind": "file_edit", "path": "src/payment/calc.ts", @@ -314,7 +315,7 @@ private static String paymentTimelineRequest() { "occurredAt": "2026-05-24T10:02:00Z" }, { - "id": "e4", + "eventId": "e4", "seq": 4, "kind": "command", "toolName": "Bash", @@ -323,7 +324,7 @@ private static String paymentTimelineRequest() { "occurredAt": "2026-05-24T10:03:00Z" }, { - "id": "e5", + "eventId": "e5", "seq": 5, "kind": "stop", "occurredAt": "2026-05-24T10:04:00Z" diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerApplicationTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerApplicationTest.java index 6da542b7..093bf914 100644 --- a/memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerApplicationTest.java +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerApplicationTest.java @@ -260,15 +260,18 @@ void commitApiAcceptsRequestWhenRuntimeIsAvailable() throws Exception { "type": "agent_timeline", "sourceClient": "claude-code", "sessionId": "session-1", + "agentTurnId": "session-1-agent-turn-1-2", "timelineId": "timeline-1", "events": [ { + "eventId": "e1", "seq": 1, "kind": "USER_PROMPT", "occurredAt": "2026-04-12T00:00:00Z", "text": "Fix failing tests" }, { + "eventId": "e2", "seq": 2, "kind": "COMMAND", "occurredAt": "2026-04-12T00:01:00Z", From e5d66148f40532219f72dfc755ffb91d02771a48 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 25 May 2026 23:20:50 +0800 Subject: [PATCH 22/54] refine agent timeline ingestion scope --- memind-integrations/claude-code/README.md | 82 ++--- .../claude-code/scripts/ingest.py | 67 +---- .../claude-code/scripts/lib/config.py | 14 - .../claude-code/scripts/lib/content.py | 41 --- .../claude-code/scripts/lib/state.py | 10 - .../claude-code/scripts/pre_compact.py | 7 +- .../claude-code/scripts/session_end.py | 2 +- .../claude-code/scripts/session_start.py | 21 +- memind-integrations/claude-code/settings.json | 7 - .../claude-code/tests/test_config.py | 8 +- .../claude-code/tests/test_content.py | 19 +- .../claude-code/tests/test_hooks.py | 141 +-------- .../claude-code/tests/test_manifest.py | 5 +- .../claude-code/tests/test_retry.py | 8 +- .../claude-code/tests/test_state.py | 11 +- memind-integrations/codex/README.md | 75 ++--- memind-integrations/codex/scripts/ingest.py | 57 +--- .../codex/scripts/lib/config.py | 10 - .../codex/scripts/lib/content.py | 47 --- .../codex/scripts/lib/state.py | 17 -- .../codex/scripts/session_start.py | 40 +-- memind-integrations/codex/settings.json | 5 - .../codex/tests/test_config.py | 10 +- .../codex/tests/test_content.py | 34 +-- memind-integrations/codex/tests/test_hooks.py | 282 +----------------- .../codex/tests/test_manifest.py | 7 +- memind-integrations/codex/tests/test_retry.py | 2 +- memind-integrations/codex/tests/test_state.py | 24 +- .../item/AgentItemExtractionStrategy.java | 34 ++- .../rawdata/agent/item/AgentItemPrompts.java | 12 +- .../AgentTimelineContentProcessor.java | 3 +- ...gentExtractionPipelineIntegrationTest.java | 14 +- .../AgentItemExtractionStrategyLlmTest.java | 50 ++++ .../AgentTimelineContentProcessorTest.java | 2 +- 34 files changed, 230 insertions(+), 938 deletions(-) diff --git a/memind-integrations/claude-code/README.md b/memind-integrations/claude-code/README.md index 344dd982..b0e79711 100644 --- a/memind-integrations/claude-code/README.md +++ b/memind-integrations/claude-code/README.md @@ -1,7 +1,7 @@ # Memind Claude Code Integration Memind adds persistent project memory to Claude Code. The plugin retrieves relevant Memind context before each -user prompt and submits Claude Code conversation messages through Memind's reliable extraction endpoint during +user prompt and submits Claude Code coding-agent timelines through Memind's reliable extraction endpoint during session lifecycle hooks. Use this plugin when you want Claude Code to remember project facts, preferences, implementation decisions, and @@ -19,12 +19,9 @@ The integration is intentionally small: - **Retrieval**: `UserPromptSubmit` calls `MemindClient.memory.retrieve(...)` and injects relevant memories into Claude Code as `...` additional context. -- **Ingestion**: `Stop`, `PreCompact`, and `SessionEnd` read the Claude Code transcript, filter - user/assistant messages, and submit a caller-owned conversation payload through - `AsyncMemindClient.memory.extract(...)`. -- **Agent timelines**: `PreToolUse` and `PostToolUse` buffer normalized tool events locally. The next - ingestion hook submits them as `rawContent.type = "agent_timeline"` so Memind can extract tool notes, - resolved problems, playbooks, and directives. +- **Ingestion**: `PreToolUse` and `PostToolUse` buffer normalized tool events locally. `Stop`, `PreCompact`, and + `SessionEnd` flush buffered events as `rawContent.type = "agent_timeline"` through + `AsyncMemindClient.memory.extract(...)`, so Memind can extract user and agent memories from the same agent turn. - **Retry**: failed ingestion payloads are spooled under `~/.memind/claude-code/retry/` and replayed on later `SessionStart` hooks. - **Source tagging**: all requests use `sourceClient = "claude-code"` by default, so Memind can distinguish @@ -106,9 +103,9 @@ The installed hooks are: | `UserPromptSubmit` | `scripts/retrieve.py` | 12s | Retrieve relevant Memind context for the current user prompt. | | `PreToolUse` | `scripts/pre_tool_use.py` | 5s | Buffer a redacted tool-start event in local session state. | | `PostToolUse` | `scripts/post_tool_use.py` | 5s | Buffer a redacted tool-result event in local session state. | -| `PreCompact` | `scripts/pre_compact.py` | 30s | Submit recent transcript messages through reliable extraction before context compaction. | -| `Stop` | `scripts/ingest.py` | 15s | Submit new transcript messages through reliable extraction after a turn. | -| `SessionEnd` | `scripts/session_end.py` | 10s | Submit remaining transcript messages through reliable extraction at session end. | +| `PreCompact` | `scripts/pre_compact.py` | 30s | Flush buffered `agent_timeline` events before context compaction. | +| `Stop` | `scripts/ingest.py` | 15s | Flush buffered `agent_timeline` events after a turn. | +| `SessionEnd` | `scripts/session_end.py` | 10s | Flush remaining buffered `agent_timeline` events at session end. | `Stop`, `PreToolUse`, and `PostToolUse` are configured as async so regular turn completion stays fast. @@ -127,9 +124,6 @@ User configuration is optional. Save overrides as `~/.memind/claude-code.json`: "agentIdMode": "project", "sourceClient": "claude-code", "autoIngestAgentTimeline": true, - "ingestionMode": "extract-sync", - "preCompactCommit": true, - "commitOnSessionEnd": true, "retrieveContextTurns": 0 } ``` @@ -152,18 +146,11 @@ Settings are loaded in this order: | `agentIdMode` | `project` | `project` appends a stable project suffix; any other value uses `agentId` as-is. | | `sourceClient` | `claude-code` | Source marker stored with Memind data. | | `autoRetrieve` | `true` | Enables prompt-time memory retrieval. | -| `autoIngest` | `true` | Enables transcript ingestion during lifecycle hooks. | -| `autoIngestAgentTimeline` | `true` | Enables `PreToolUse`/`PostToolUse` event flush as `agent_timeline` raw data. | +| `autoIngestAgentTimeline` | `true` | Enables `PreToolUse`/`PostToolUse` event buffering and `agent_timeline` rawdata flush. | | `retrieveStrategy` | `SIMPLE` | Memind retrieval strategy. | | `retrieveMaxEntries` | `8` | Maximum formatted memory entries injected into Claude Code. | | `retrieveMaxChars` | `6000` | Maximum injected context characters. | | `retrieveContextTurns` | `0` | Number of recent transcript turns to include in the retrieval query. | -| `ingestionMode` | `extract-sync` | Default reliable ingestion mode. | -| `ingestionRoles` | `["user", "assistant"]` | Transcript roles eligible for ingestion. | -| `ingestionMaxMessagesPerHook` | `20` | Maximum new messages sent during one regular ingestion hook. | -| `preCompactCommit` | `true` | Compatibility flag for server-buffer ingestion mode; ignored by the default reliable mode. | -| `preCompactMaxMessages` | `20` | Maximum messages submitted during one `PreCompact` hook. | -| `commitOnSessionEnd` | `true` | Compatibility flag for server-buffer ingestion mode; ignored by the default reliable mode. | | `ingestRetrySpool` | `true` | Enables file-backed retry for failed extraction payloads. | | `debug` | `false` | Writes debug logs to `~/.memind/claude-code.log`. | @@ -179,17 +166,13 @@ export MEMIND_AGENT_ID=claude-code export MEMIND_AGENT_ID_MODE=project export MEMIND_SOURCE_CLIENT=claude-code export MEMIND_AUTO_INGEST_AGENT_TIMELINE=true -export MEMIND_INGESTION_MODE=extract-sync -export MEMIND_PRE_COMPACT_COMMIT=true -export MEMIND_COMMIT_ON_SESSION_END=true export MEMIND_RETRIEVE_CONTEXT_TURNS=0 export MEMIND_DEBUG=true ``` -Additional environment variables include `MEMIND_AUTO_RETRIEVE`, `MEMIND_AUTO_INGEST`, -`MEMIND_RETRIEVE_STRATEGY`, `MEMIND_INGESTION_MODE`, `MEMIND_INGESTION_ROLES`, -`MEMIND_INGESTION_MAX_MESSAGES_PER_HOOK`, `MEMIND_PRE_COMPACT_MAX_MESSAGES`, -`MEMIND_STATE_MAX_AGE_DAYS`, `MEMIND_INGEST_RETRY_SPOOL`, `MEMIND_INGEST_RETRY_MAX_FILES`, and +Additional environment variables include `MEMIND_AUTO_RETRIEVE`, `MEMIND_RETRIEVE_STRATEGY`, +`MEMIND_RETRIEVE_MAX_ENTRIES`, `MEMIND_RETRIEVE_MAX_CHARS`, `MEMIND_STATE_MAX_AGE_DAYS`, +`MEMIND_INGEST_RETRY_SPOOL`, `MEMIND_INGEST_RETRY_MAX_FILES`, and `MEMIND_INGEST_RETRY_MAX_AGE_DAYS`. ## Identity Model @@ -245,24 +228,9 @@ Agent memory items are grouped separately when returned by Memind: ## Ingestion Behavior -Ingestion reads Claude Code's transcript when `autoIngest = true`. - -The ingestion flow: - -1. Reads the Claude Code JSONL transcript. -2. Extracts user and assistant message text. -3. Strips previously injected `` blocks to avoid feedback loops. -4. Skips tool/event payloads, unsupported roles, and Claude Code interruption placeholders. -5. Computes stable fingerprints and sends only messages that have not already been submitted. -6. Builds one caller-owned conversation raw-content payload and submits it through - `AsyncMemindClient.memory.extract(...)`. - -The local retry spool stores the full extraction payload plus the covered message fingerprints. Fingerprints are -marked submitted only after Memind returns `SUCCESS`; `PARTIAL_SUCCESS` and failures keep the payload available -for later replay. - -Tool and command events are buffered under `~/.memind/claude-code/state/` and flushed with the same reliable -extraction path. A typical timeline payload looks like: +Ingestion is timeline-only for Claude Code. The plugin does not submit transcript conversation rawdata. It buffers +tool and command events under `~/.memind/claude-code/state/` and flushes them through +`AsyncMemindClient.memory.extract(...)` as agent timeline rawdata. A typical timeline payload looks like: ```json { @@ -295,8 +263,8 @@ extraction path. A typical timeline payload looks like: Secrets are redacted before events are written to local state. File content capture is disabled by default; the hook stores normalized tool metadata, commands, paths, statuses, and compact outputs. -Commit flags apply only to explicit server-buffer mode. In the default reliable mode, hooks do not issue an -additional `/commit` call after successful `/extract/sync`. +On `SUCCESS`, the covered events are removed from local state. `PARTIAL_SUCCESS` and failures keep the events +available and spool the full timeline payload for later `SessionStart` replay. ## Server RawData Agent Settings @@ -463,28 +431,20 @@ curl -fsSL http://127.0.0.1:8366/open/v1/health - Confirm existing memories are stored under the same `userId` and `agentId`. - Try setting `retrieveContextTurns` to `1` or `2` if the current prompt is very short. -### Messages are not ingested +### Agent timeline events are not ingested -- Confirm `autoIngest` is `true`. -- Confirm Claude Code provides `transcript_path` in hook payloads. +- Confirm `autoIngestAgentTimeline` is `true`. +- Confirm Claude Code is emitting `PreToolUse` and `PostToolUse` hooks. - Confirm `~/.memind/claude-code/state/` is writable. - Enable `MEMIND_DEBUG=true` and inspect `~/.memind/claude-code.log`. ### New memories are not immediately retrieved -The default reliable mode submits transcript batches through `AsyncMemindClient.memory.extract(...)`, so a -`SUCCESS` response means extraction finished for that batch. If retrieval still does not surface the expected +The default reliable mode submits agent timelines through `AsyncMemindClient.memory.extract(...)`, so a `SUCCESS` +response means extraction finished for that timeline payload. If retrieval still does not surface the expected memory, confirm the same `userId` and `agentId` are used for ingestion and retrieval, then inspect `~/.memind/claude-code.log` with `MEMIND_DEBUG=true`. -### Duplicate messages appear - -The integration uses per-session fingerprints stored under `~/.memind/claude-code/state/`. If duplicates appear: - -- Confirm the state directory is writable. -- Check whether Claude Code transcript identifiers changed across sessions. -- Remove stale local state only if you accept that old transcript messages may be re-submitted. - ## Limitations - Exact duplicate complete timeline windows are idempotent. diff --git a/memind-integrations/claude-code/scripts/ingest.py b/memind-integrations/claude-code/scripts/ingest.py index 4028bdf2..e416a399 100644 --- a/memind-integrations/claude-code/scripts/ingest.py +++ b/memind-integrations/claude-code/scripts/ingest.py @@ -24,7 +24,6 @@ from lib.client import MemindClient from lib.agent_timeline import build_timeline_payload from lib.config import load_config -from lib.content import extract_messages from lib.identity import resolve_identity from lib.logging_utils import debug_log from lib.retry import RetrySpool @@ -45,33 +44,6 @@ def retry_root(): return Path.home() / ".memind" / "claude-code" / "retry" -def _message_payload(raw_message): - return {key: value for key, value in raw_message.items() if key != "fingerprint"} - - -def _extract_payload(messages): - return { - "type": "conversation", - "messages": [_message_payload(message) for message in messages], - } - - -def _spool_extract(retry_spool, identity, source_client, session_id, messages): - if retry_spool is None or not messages: - return - retry_spool.enqueue( - { - "kind": "extract", - "userId": identity["userId"], - "agentId": identity["agentId"], - "sourceClient": source_client, - "sessionId": session_id, - "fingerprints": [message["fingerprint"] for message in messages], - "rawContent": _extract_payload(messages), - } - ) - - def _spool_agent_timeline(retry_spool, identity, source_client, session_id, events, raw_content): if retry_spool is None or not events: return @@ -88,40 +60,15 @@ def _spool_agent_timeline(retry_spool, identity, source_client, session_id, even ) -async def ingest_messages_async(config, hook_input, commit=False, max_messages=None): +async def ingest_messages_async(config, hook_input): identity = resolve_identity(config, hook_input) client = MemindClient(config["memindApiUrl"], config.get("memindApiToken"), timeout=10, max_retries=0) - transcript_path = hook_input.get("transcript_path") - messages = [] - if config.get("autoIngest", True) and transcript_path and Path(transcript_path).exists(): - messages = extract_messages(transcript_path, config.get("ingestionRoles", ["user", "assistant"])) - limit = int(max_messages or config.get("ingestionMaxMessagesPerHook", 20)) retry_spool = RetrySpool(retry_root()) if config.get("ingestRetrySpool", True) else None store = SessionStateStore(state_root()) - submitted = [] session_id = hook_input.get("session_id") or "unknown-session" source_client = config.get("sourceClient") + agent_events_submitted = 0 with store.locked(session_id) as state: - new_messages = [message for message in messages if not state.is_submitted(message["fingerprint"])] - selected = new_messages[:limit] - if selected: - response = None - try: - response = await client.extract( - identity["userId"], - identity["agentId"], - _extract_payload(selected), - source_client, - ) - except Exception: - _spool_extract(retry_spool, identity, source_client, session_id, selected) - else: - status = getattr(response, "status", None) - if status == "SUCCESS": - submitted = [message["fingerprint"] for message in selected] - else: - _spool_extract(retry_spool, identity, source_client, session_id, selected) - state.mark_submitted(submitted) agent_events = state.agent_events() if config.get("autoIngestAgentTimeline", True) else [] if agent_events: timeline_payload = build_timeline_payload( @@ -150,6 +97,7 @@ async def ingest_messages_async(config, hook_input, commit=False, max_messages=N else: status = getattr(response, "status", None) if status == "SUCCESS": + agent_events_submitted = len(agent_events) state.clear_agent_events( [event["eventId"] for event in agent_events if event.get("eventId")] ) @@ -162,19 +110,18 @@ async def ingest_messages_async(config, hook_input, commit=False, max_messages=N agent_events, timeline_payload, ) - committed = False - return {"submitted": len(submitted), "committed": committed} + return {"agentEventsSubmitted": agent_events_submitted, "committed": False} -def ingest_messages(config, hook_input, commit=False, max_messages=None): - return asyncio.run(ingest_messages_async(config, hook_input, commit=commit, max_messages=max_messages)) +def ingest_messages(config, hook_input): + return asyncio.run(ingest_messages_async(config, hook_input)) def main(): try: hook_input = json.loads(sys.stdin.read() or "{}") config = load_config() - ingest_messages(config, hook_input, commit=False) + ingest_messages(config, hook_input) except Exception as exc: try: debug_log(load_config(), "ingest_failed", {"error": str(exc)}) diff --git a/memind-integrations/claude-code/scripts/lib/config.py b/memind-integrations/claude-code/scripts/lib/config.py index e8908dc8..22a093c3 100644 --- a/memind-integrations/claude-code/scripts/lib/config.py +++ b/memind-integrations/claude-code/scripts/lib/config.py @@ -24,19 +24,12 @@ "agentIdMode": "project", "sourceClient": "claude-code", "autoRetrieve": True, - "autoIngest": True, "autoIngestAgentTimeline": True, "retrieveStrategy": "SIMPLE", "retrieveMaxEntries": 8, "retrieveMaxChars": 6000, "retrievePromptPreamble": "Relevant memories from Memind. Use only when directly helpful:", "retrieveContextTurns": 0, - "ingestionMode": "extract-sync", - "ingestionRoles": ["user", "assistant"], - "ingestionMaxMessagesPerHook": 20, - "preCompactCommit": True, - "preCompactMaxMessages": 20, - "commitOnSessionEnd": True, "stateMaxAgeDays": 14, "ingestRetrySpool": True, "ingestRetryMaxFiles": 20, @@ -52,16 +45,9 @@ "MEMIND_AGENT_ID_MODE": ("agentIdMode", str), "MEMIND_SOURCE_CLIENT": ("sourceClient", str), "MEMIND_AUTO_RETRIEVE": ("autoRetrieve", "bool"), - "MEMIND_AUTO_INGEST": ("autoIngest", "bool"), "MEMIND_AUTO_INGEST_AGENT_TIMELINE": ("autoIngestAgentTimeline", "bool"), "MEMIND_RETRIEVE_STRATEGY": ("retrieveStrategy", str), "MEMIND_RETRIEVE_CONTEXT_TURNS": ("retrieveContextTurns", "int_allow_zero"), - "MEMIND_INGESTION_MODE": ("ingestionMode", str), - "MEMIND_INGESTION_ROLES": ("ingestionRoles", "list"), - "MEMIND_INGESTION_MAX_MESSAGES_PER_HOOK": ("ingestionMaxMessagesPerHook", "int"), - "MEMIND_PRE_COMPACT_COMMIT": ("preCompactCommit", "bool"), - "MEMIND_PRE_COMPACT_MAX_MESSAGES": ("preCompactMaxMessages", "int"), - "MEMIND_COMMIT_ON_SESSION_END": ("commitOnSessionEnd", "bool"), "MEMIND_STATE_MAX_AGE_DAYS": ("stateMaxAgeDays", "int"), "MEMIND_INGEST_RETRY_SPOOL": ("ingestRetrySpool", "bool"), "MEMIND_INGEST_RETRY_MAX_FILES": ("ingestRetryMaxFiles", "int"), diff --git a/memind-integrations/claude-code/scripts/lib/content.py b/memind-integrations/claude-code/scripts/lib/content.py index 894e61be..e7b71720 100644 --- a/memind-integrations/claude-code/scripts/lib/content.py +++ b/memind-integrations/claude-code/scripts/lib/content.py @@ -12,7 +12,6 @@ # limitations under the License. # -import hashlib import json import re from pathlib import Path @@ -58,46 +57,6 @@ def _text_blocks(content): return [] -def _fingerprint(entry, role, text): - source = json.dumps( - { - "uuid": entry.get("uuid"), - "timestamp": entry.get("timestamp"), - "type": entry.get("type"), - "role": role, - "text": text, - }, - sort_keys=True, - ensure_ascii=False, - ) - return hashlib.sha1(source.encode("utf-8")).hexdigest() - - -def extract_messages(path, roles): - allowed = {role.lower() for role in roles} - messages = [] - for entry in _parse_jsonl(path): - entry_type = str(entry.get("type", "")).lower() - if entry_type not in allowed or entry_type not in {"user", "assistant"}: - continue - content = (entry.get("message") or {}).get("content") - texts = _text_blocks(content) - if not texts: - continue - role = "USER" if entry_type == "user" else "ASSISTANT" - for text in texts: - messages.append( - { - "fingerprint": _fingerprint(entry, role, text), - "role": role, - "content": [{"type": "text", "text": text}], - "timestamp": entry.get("timestamp"), - "userName": entry.get("user_name"), - } - ) - return messages - - def _tail_lines(path, max_bytes=65536): path = Path(path) with path.open("rb") as handle: diff --git a/memind-integrations/claude-code/scripts/lib/state.py b/memind-integrations/claude-code/scripts/lib/state.py index e974d9b3..7be16acf 100644 --- a/memind-integrations/claude-code/scripts/lib/state.py +++ b/memind-integrations/claude-code/scripts/lib/state.py @@ -30,19 +30,9 @@ def _safe_session_id(session_id): class SessionState: def __init__(self, data): self.data = data - self.data.setdefault("submitted", []) self.data.setdefault("agentEvents", []) self.data.setdefault("nextAgentSeq", 1) - def is_submitted(self, fingerprint): - return fingerprint in set(self.data.get("submitted", [])) - - def mark_submitted(self, fingerprints): - submitted = set(self.data.get("submitted", [])) - submitted.update(fingerprints) - self.data["submitted"] = sorted(submitted) - self.data["updatedAt"] = time.time() - def append_agent_event(self, event): events = list(self.data.get("agentEvents", [])) event_id = event.get("eventId") diff --git a/memind-integrations/claude-code/scripts/pre_compact.py b/memind-integrations/claude-code/scripts/pre_compact.py index 267a80f2..bacf831d 100644 --- a/memind-integrations/claude-code/scripts/pre_compact.py +++ b/memind-integrations/claude-code/scripts/pre_compact.py @@ -28,12 +28,7 @@ def main(): try: hook_input = json.loads(sys.stdin.read() or "{}") config = load_config() - ingest_messages( - config, - hook_input, - commit=bool(config.get("preCompactCommit", True)), - max_messages=int(config.get("preCompactMaxMessages", 20)), - ) + ingest_messages(config, hook_input) except Exception as exc: try: debug_log(load_config(), "pre_compact_failed", {"error": str(exc)}) diff --git a/memind-integrations/claude-code/scripts/session_end.py b/memind-integrations/claude-code/scripts/session_end.py index 16812c5c..ed968779 100644 --- a/memind-integrations/claude-code/scripts/session_end.py +++ b/memind-integrations/claude-code/scripts/session_end.py @@ -28,7 +28,7 @@ def main(): try: hook_input = json.loads(sys.stdin.read() or "{}") config = load_config() - ingest_messages(config, hook_input, commit=bool(config.get("commitOnSessionEnd", True))) + ingest_messages(config, hook_input) except Exception as exc: try: debug_log(load_config(), "session_end_failed", {"error": str(exc)}) diff --git a/memind-integrations/claude-code/scripts/session_start.py b/memind-integrations/claude-code/scripts/session_start.py index 902427ea..7daa3ec8 100644 --- a/memind-integrations/claude-code/scripts/session_start.py +++ b/memind-integrations/claude-code/scripts/session_start.py @@ -30,6 +30,11 @@ from lib.state import SessionStateStore +def _is_agent_timeline_extract(payload): + raw_content = payload.get("rawContent") if isinstance(payload, dict) else None + return payload.get("kind") == "extract" and raw_content and raw_content.get("type") == "agent_timeline" + + def _tcp_check(url, timeout=1): parsed = urlparse(url) host = parsed.hostname or "127.0.0.1" @@ -53,7 +58,7 @@ async def _run_session_start_async(config): if claimed: payload = spool.load_claimed(claimed) replay_client = MemindClient(config["memindApiUrl"], config.get("memindApiToken"), timeout=10, max_retries=0) - if payload.get("kind") == "extract": + if _is_agent_timeline_extract(payload): response = await replay_client.extract( payload["userId"], payload["agentId"], @@ -63,24 +68,10 @@ async def _run_session_start_async(config): status = getattr(response, "status", None) if status != "SUCCESS": raise RuntimeError(f"extract replay did not fully succeed: {status}") - if payload.get("sessionId") and payload.get("fingerprints"): - with SessionStateStore(state_root()).locked(payload["sessionId"]) as state: - state.mark_submitted(payload["fingerprints"]) if payload.get("sessionId") and payload.get("eventIds"): with SessionStateStore(state_root()).locked(payload["sessionId"]) as state: state.clear_agent_events(payload["eventIds"]) spool.complete(claimed) - elif payload.get("kind") == "add-message": - await replay_client.add_message( - payload["userId"], - payload["agentId"], - payload["message"], - payload.get("sourceClient"), - ) - if payload.get("sessionId") and payload.get("fingerprint"): - with SessionStateStore(state_root()).locked(payload["sessionId"]) as state: - state.mark_submitted([payload["fingerprint"]]) - spool.complete(claimed) elif payload.get("kind") == "commit": await replay_client.commit(payload["userId"], payload["agentId"], payload.get("sourceClient")) spool.complete(claimed) diff --git a/memind-integrations/claude-code/settings.json b/memind-integrations/claude-code/settings.json index b7e46339..bbe89c51 100644 --- a/memind-integrations/claude-code/settings.json +++ b/memind-integrations/claude-code/settings.json @@ -6,19 +6,12 @@ "agentIdMode": "project", "sourceClient": "claude-code", "autoRetrieve": true, - "autoIngest": true, "autoIngestAgentTimeline": true, "retrieveStrategy": "SIMPLE", "retrieveMaxEntries": 8, "retrieveMaxChars": 6000, "retrievePromptPreamble": "Relevant memories from Memind. Use only when directly helpful:", "retrieveContextTurns": 0, - "ingestionMode": "extract-sync", - "ingestionRoles": ["user", "assistant"], - "ingestionMaxMessagesPerHook": 20, - "preCompactCommit": true, - "preCompactMaxMessages": 20, - "commitOnSessionEnd": true, "stateMaxAgeDays": 14, "ingestRetrySpool": true, "ingestRetryMaxFiles": 20, diff --git a/memind-integrations/claude-code/tests/test_config.py b/memind-integrations/claude-code/tests/test_config.py index e3543ff0..2f82a5cb 100644 --- a/memind-integrations/claude-code/tests/test_config.py +++ b/memind-integrations/claude-code/tests/test_config.py @@ -42,10 +42,11 @@ def test_parse_list(self): def test_defaults_match_spec(self): self.assertEqual(DEFAULT_SETTINGS["retrieveContextTurns"], 0) - self.assertEqual(DEFAULT_SETTINGS["ingestionMode"], "extract-sync") self.assertEqual(DEFAULT_SETTINGS["sourceClient"], "claude-code") self.assertTrue(DEFAULT_SETTINGS["autoIngestAgentTimeline"]) - self.assertEqual(DEFAULT_SETTINGS["ingestionMaxMessagesPerHook"], 20) + self.assertNotIn("autoIngest", DEFAULT_SETTINGS) + self.assertNotIn("ingestionRoles", DEFAULT_SETTINGS) + self.assertNotIn("ingestionMaxMessagesPerHook", DEFAULT_SETTINGS) self.assertEqual(DEFAULT_SETTINGS["stateMaxAgeDays"], 14) def test_environment_overrides(self): @@ -56,7 +57,6 @@ def test_environment_overrides(self): "MEMIND_API_URL": "http://memind.example", "MEMIND_AUTO_RETRIEVE": "false", "MEMIND_AUTO_INGEST_AGENT_TIMELINE": "false", - "MEMIND_INGESTION_ROLES": "user,assistant", "MEMIND_STATE_MAX_AGE_DAYS": "30", } with patch.dict(os.environ, env, clear=False): @@ -64,7 +64,7 @@ def test_environment_overrides(self): self.assertEqual(config["memindApiUrl"], "http://memind.example") self.assertFalse(config["autoRetrieve"]) self.assertFalse(config["autoIngestAgentTimeline"]) - self.assertEqual(config["ingestionRoles"], ["user", "assistant"]) + self.assertNotIn("ingestionRoles", config) self.assertEqual(config["stateMaxAgeDays"], 30) self.assertEqual(config["retrieveMaxEntries"], 3) diff --git a/memind-integrations/claude-code/tests/test_content.py b/memind-integrations/claude-code/tests/test_content.py index 9ee3067e..e89d1d8e 100644 --- a/memind-integrations/claude-code/tests/test_content.py +++ b/memind-integrations/claude-code/tests/test_content.py @@ -17,7 +17,7 @@ import unittest from pathlib import Path -from scripts.lib.content import extract_messages, read_recent_context, strip_memind_blocks +from scripts.lib.content import read_recent_context, strip_memind_blocks class ContentTest(unittest.TestCase): @@ -25,7 +25,7 @@ def test_strip_memind_blocks(self): text = "before secret after" self.assertEqual(strip_memind_blocks(text), "before after") - def test_extract_messages_skips_tools_and_unknown_lines(self): + def test_read_recent_context_skips_tools_and_unknown_lines(self): lines = [ {"type": "user", "timestamp": "2026-04-28T00:00:00Z", "message": {"content": "hello"}}, { @@ -49,15 +49,14 @@ def test_extract_messages_skips_tools_and_unknown_lines(self): handle.write(json.dumps(entry) + "\n") path = Path(handle.name) try: - messages = extract_messages(path, roles=["user", "assistant"]) + context = read_recent_context(path, turns=2) finally: path.unlink() - self.assertEqual([m["role"] for m in messages], ["USER", "ASSISTANT"]) - self.assertEqual(messages[0]["content"][0]["text"], "hello") - self.assertEqual(messages[1]["content"][0]["text"], "answer\n\nmore") - + self.assertIn("user: hello", context) + self.assertIn("assistant: answer\n\nmore", context) + self.assertNotIn("file", context) - def test_extract_messages_skips_claude_code_interrupt_placeholders(self): + def test_read_recent_context_skips_claude_code_interrupt_placeholders(self): lines = [ {"type": "user", "message": {"content": "[Request interrupted by user]"}}, {"type": "assistant", "message": {"content": "[Request interrupted by user]"}}, @@ -68,10 +67,10 @@ def test_extract_messages_skips_claude_code_interrupt_placeholders(self): handle.write(json.dumps(entry) + "\n") path = Path(handle.name) try: - messages = extract_messages(path, roles=["user", "assistant"]) + context = read_recent_context(path, turns=2) finally: path.unlink() - self.assertEqual([m["content"][0]["text"] for m in messages], ["real instruction"]) + self.assertEqual(context, "user: real instruction") def test_read_recent_context_reads_tail(self): with tempfile.NamedTemporaryFile("w", delete=False) as handle: diff --git a/memind-integrations/claude-code/tests/test_hooks.py b/memind-integrations/claude-code/tests/test_hooks.py index 155e6f88..233b8511 100644 --- a/memind-integrations/claude-code/tests/test_hooks.py +++ b/memind-integrations/claude-code/tests/test_hooks.py @@ -206,7 +206,7 @@ def test_session_start_fail_open_when_memind_unavailable(self): output = self.run_hook("session_start.py", {"cwd": tmp, "session_id": "s1"}, env=env) self.assertEqual(output, {"continue": True, "suppressOutput": True}) - def test_ingest_uses_extract_sync_payload_and_marks_submitted_only_on_success(self): + def test_ingest_ignores_transcript_when_no_agent_events(self): sys.path.insert(0, str(ROOT / "scripts")) import ingest @@ -227,9 +227,7 @@ def test_ingest_uses_extract_sync_payload_and_marks_submitted_only_on_success(se config = { "memindApiUrl": "http://127.0.0.1:8366", "memindApiToken": None, - "autoIngest": True, - "ingestionRoles": ["user", "assistant"], - "ingestionMaxMessagesPerHook": 20, + "autoIngestAgentTimeline": True, "ingestRetrySpool": True, "sourceClient": "claude-code", "agentId": "claude-code", @@ -250,70 +248,12 @@ def test_ingest_uses_extract_sync_payload_and_marks_submitted_only_on_success(se "transcript_path": str(transcript), "cwd": tmp, }, - commit=True, ) - self.assertEqual(result["submitted"], 1) + self.assertEqual(result["agentEventsSubmitted"], 0) self.assertFalse(result["committed"]) - client.extract.assert_awaited_once() + client.extract.assert_not_awaited() client.commit.assert_not_awaited() - raw_content = client.extract.await_args.args[2] - self.assertEqual(raw_content["type"], "conversation") - self.assertEqual(raw_content["messages"][0]["role"], "USER") - finally: - transcript.unlink() - - def test_ingest_spools_full_extract_payload_on_partial_success(self): - sys.path.insert(0, str(ROOT / "scripts")) - import ingest - - with tempfile.NamedTemporaryFile("w", delete=False) as handle: - handle.write( - json.dumps( - { - "type": "user", - "uuid": "msg-1", - "timestamp": "2026-05-11T00:00:00Z", - "message": {"content": "remember espresso"}, - } - ) - + "\n" - ) - transcript = Path(handle.name) - try: - config = { - "memindApiUrl": "http://127.0.0.1:8366", - "memindApiToken": None, - "autoIngest": True, - "ingestionRoles": ["user", "assistant"], - "ingestionMaxMessagesPerHook": 20, - "ingestRetrySpool": True, - "sourceClient": "claude-code", - "agentId": "claude-code", - "agentIdMode": "global", - "userId": "u", - } - with tempfile.TemporaryDirectory() as tmp: - retry_dir = Path(tmp) / "retry" - with mock.patch.object(ingest, "state_root", return_value=Path(tmp) / "state"): - with mock.patch.object(ingest, "retry_root", return_value=retry_dir): - with mock.patch.object(ingest, "MemindClient") as client_cls: - client = client_cls.return_value - client.extract = mock.AsyncMock(return_value=types.SimpleNamespace(status="PARTIAL_SUCCESS")) - client.commit = mock.AsyncMock(return_value=None) - result = ingest.ingest_messages( - config, - { - "session_id": "s1", - "transcript_path": str(transcript), - "cwd": tmp, - }, - commit=False, - ) - payload = json.loads(next(retry_dir.glob("*.json")).read_text()) - self.assertEqual(result["submitted"], 0) - self.assertEqual(payload["kind"], "extract") - self.assertEqual(payload["rawContent"]["type"], "conversation") - self.assertEqual(len(payload["fingerprints"]), 1) + self.assertEqual(list((Path(tmp) / "retry").glob("*.json")), []) finally: transcript.unlink() @@ -325,7 +265,6 @@ def test_ingest_flushes_agent_timeline_and_clears_events_on_success(self): config = { "memindApiUrl": "http://127.0.0.1:8366", "memindApiToken": None, - "autoIngest": False, "autoIngestAgentTimeline": True, "ingestRetrySpool": True, "sourceClient": "claude-code", @@ -351,7 +290,7 @@ def test_ingest_flushes_agent_timeline_and_clears_events_on_success(self): "cwd": tmp, }, ) - self.assertEqual(result["submitted"], 0) + self.assertEqual(result["agentEventsSubmitted"], 1) client.extract.assert_awaited_once() raw_content = client.extract.await_args.args[2] self.assertEqual(raw_content["type"], "agent_timeline") @@ -368,7 +307,6 @@ def test_ingest_spools_agent_timeline_on_partial_success(self): config = { "memindApiUrl": "http://127.0.0.1:8366", "memindApiToken": None, - "autoIngest": False, "autoIngestAgentTimeline": True, "ingestRetrySpool": True, "sourceClient": "claude-code", @@ -405,11 +343,10 @@ def test_ingest_spools_agent_timeline_on_partial_success(self): with SessionStateStore(state_dir).locked("s1") as state: self.assertEqual(len(state.agent_events()), 1) - def test_session_start_replays_extract_payload_and_marks_fingerprints(self): + def test_session_start_discards_legacy_conversation_extract_payload(self): sys.path.insert(0, str(ROOT / "scripts")) import session_start from scripts.lib.retry import RetrySpool - from scripts.lib.state import SessionStateStore with tempfile.TemporaryDirectory() as tmp: retry_dir = Path(tmp) / "retry" @@ -421,7 +358,6 @@ def test_session_start_replays_extract_payload_and_marks_fingerprints(self): "agentId": "a", "sourceClient": "claude-code", "sessionId": "s1", - "fingerprints": ["fp1"], "rawContent": { "type": "conversation", "messages": [ @@ -448,61 +384,11 @@ def test_session_start_replays_extract_payload_and_marks_fingerprints(self): client.add_message = mock.AsyncMock(return_value=None) client.commit = mock.AsyncMock(return_value=None) session_start.main() - with SessionStateStore(state_dir).locked("s1") as state: - self.assertTrue(state.is_submitted("fp1")) self.assertEqual(list(retry_dir.glob("*.json")), []) - client.extract.assert_awaited_once() + client.extract.assert_not_awaited() client.add_message.assert_not_awaited() client.commit.assert_not_awaited() - def test_session_start_keeps_extract_payload_when_replay_is_not_success(self): - sys.path.insert(0, str(ROOT / "scripts")) - import session_start - from scripts.lib.retry import RetrySpool - from scripts.lib.state import SessionStateStore - - with tempfile.TemporaryDirectory() as tmp: - retry_dir = Path(tmp) / "retry" - state_dir = Path(tmp) / "state" - RetrySpool(retry_dir).enqueue( - { - "kind": "extract", - "userId": "u", - "agentId": "a", - "sourceClient": "claude-code", - "sessionId": "s1", - "fingerprints": ["fp1"], - "rawContent": { - "type": "conversation", - "messages": [ - {"role": "USER", "content": [{"type": "text", "text": "hello"}]} - ], - }, - } - ) - config = { - "memindApiUrl": "http://127.0.0.1:8366", - "memindApiToken": None, - "ingestRetryMaxFiles": 20, - "ingestRetryMaxAgeDays": 7, - "stateMaxAgeDays": 14, - "debug": False, - } - with mock.patch.object(session_start, "load_config", return_value=config): - with mock.patch.object(session_start, "retry_root", return_value=retry_dir): - with mock.patch.object(session_start, "state_root", return_value=state_dir): - with mock.patch.object(session_start, "MemindClient") as client_cls: - client = client_cls.return_value - client.health = mock.AsyncMock(return_value=types.SimpleNamespace(status="UP")) - client.extract = mock.AsyncMock(return_value=types.SimpleNamespace(status="PARTIAL_SUCCESS")) - client.add_message = mock.AsyncMock(return_value=None) - client.commit = mock.AsyncMock(return_value=None) - session_start.main() - with SessionStateStore(state_dir).locked("s1") as state: - self.assertFalse(state.is_submitted("fp1")) - self.assertEqual(len(list(retry_dir.glob("*.json"))), 1) - client.extract.assert_awaited_once() - def test_session_start_replays_agent_timeline_payload_and_clears_event_ids(self): sys.path.insert(0, str(ROOT / "scripts")) import session_start @@ -574,12 +460,13 @@ def test_session_start_recovers_orphaned_claims_before_replay(self): "agentId": "a", "sourceClient": "claude-code", "sessionId": "s1", - "fingerprints": ["fp1"], + "eventIds": ["e1"], "rawContent": { - "type": "conversation", - "messages": [ - {"role": "USER", "content": [{"type": "text", "text": "hello"}]} - ], + "type": "agent_timeline", + "sourceClient": "claude-code", + "sessionId": "s1", + "timelineId": "s1-agent", + "events": [{"eventId": "e1", "seq": 1, "kind": "command"}], }, } ) diff --git a/memind-integrations/claude-code/tests/test_manifest.py b/memind-integrations/claude-code/tests/test_manifest.py index a3517931..24c0cfb8 100644 --- a/memind-integrations/claude-code/tests/test_manifest.py +++ b/memind-integrations/claude-code/tests/test_manifest.py @@ -49,9 +49,10 @@ def test_hooks_json_shape(self): def test_default_settings(self): settings = json.loads((ROOT / "settings.json").read_text()) self.assertEqual(settings["retrieveContextTurns"], 0) - self.assertEqual(settings["ingestionMode"], "extract-sync") self.assertTrue(settings["autoIngestAgentTimeline"]) - self.assertEqual(settings["ingestionMaxMessagesPerHook"], 20) + self.assertNotIn("autoIngest", settings) + self.assertNotIn("ingestionRoles", settings) + self.assertNotIn("ingestionMaxMessagesPerHook", settings) self.assertEqual(settings["stateMaxAgeDays"], 14) diff --git a/memind-integrations/claude-code/tests/test_retry.py b/memind-integrations/claude-code/tests/test_retry.py index be03a46f..00c6dd2b 100644 --- a/memind-integrations/claude-code/tests/test_retry.py +++ b/memind-integrations/claude-code/tests/test_retry.py @@ -26,16 +26,16 @@ class RetrySpoolTest(unittest.TestCase): def test_enqueue_writes_owner_only_payload(self): with tempfile.TemporaryDirectory() as tmp: spool = RetrySpool(Path(tmp)) - path = spool.enqueue({"kind": "add-message", "payload": {"x": 1}}) - self.assertEqual(json.loads(path.read_text())["kind"], "add-message") + path = spool.enqueue({"kind": "extract", "rawContent": {"type": "agent_timeline"}}) + self.assertEqual(json.loads(path.read_text())["kind"], "extract") self.assertEqual(path.stat().st_mode & 0o777, 0o600) def test_cleanup_discards_oldest_over_limit(self): with tempfile.TemporaryDirectory() as tmp: spool = RetrySpool(Path(tmp)) - first = spool.enqueue({"kind": "add-message", "payload": {"n": 1}}) + first = spool.enqueue({"kind": "extract", "payload": {"n": 1}}) time.sleep(0.001) - second = spool.enqueue({"kind": "add-message", "payload": {"n": 2}}) + second = spool.enqueue({"kind": "extract", "payload": {"n": 2}}) spool.cleanup(max_files=1, max_age_days=7) self.assertFalse(first.exists()) self.assertTrue(second.exists()) diff --git a/memind-integrations/claude-code/tests/test_state.py b/memind-integrations/claude-code/tests/test_state.py index ab7c7051..0515f601 100644 --- a/memind-integrations/claude-code/tests/test_state.py +++ b/memind-integrations/claude-code/tests/test_state.py @@ -22,21 +22,12 @@ class StateTest(unittest.TestCase): - def test_marks_and_loads_submitted_fingerprints(self): - with tempfile.TemporaryDirectory() as tmp: - store = SessionStateStore(Path(tmp)) - with store.locked("session-1") as state: - state.mark_submitted(["a", "b"]) - with store.locked("session-1") as state: - self.assertTrue(state.is_submitted("a")) - self.assertFalse(state.is_submitted("c")) - def test_cleanup_removes_old_state(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) store = SessionStateStore(root) with store.locked("old") as state: - state.mark_submitted(["x"]) + state.append_agent_event({"eventId": "e1", "seq": 1}) old_file = root / "old.json" old_time = time.time() - 30 * 86400 os.utime(old_file, (old_time, old_time)) diff --git a/memind-integrations/codex/README.md b/memind-integrations/codex/README.md index f3e35ae1..9311a0df 100644 --- a/memind-integrations/codex/README.md +++ b/memind-integrations/codex/README.md @@ -1,7 +1,7 @@ # Memind Codex Integration Memind adds persistent project memory to Codex CLI. The integration retrieves relevant Memind context before -each user prompt and submits Codex conversation messages through Memind's reliable extraction endpoint after +each user prompt and submits Codex coding-agent timelines through Memind's reliable extraction endpoint after each turn. Use this integration when you want Codex to remember project facts, preferences, and previous decisions across @@ -18,12 +18,10 @@ The integration is intentionally small: - **Retrieval**: `UserPromptSubmit` calls `MemindClient.memory.retrieve(...)` and injects relevant memories into the Codex prompt as `...`. -- **Ingestion**: `Stop` reads the Codex transcript, filters user/assistant messages, and submits a caller-owned - conversation payload through `AsyncMemindClient.memory.extract(...)`. -- **Agent timelines**: `PreToolUse` and `PostToolUse` buffer normalized tool events locally. The next `Stop` - hook submits them as `rawContent.type = "agent_timeline"` so Memind can extract tool notes, resolved problems, - playbooks, and directives. -- **Retry**: failed ingestion batches are spooled under `~/.memind/codex/retry/` and replayed on the next +- **Ingestion**: `PreToolUse` and `PostToolUse` buffer normalized tool events locally. The next `Stop` hook + submits them as `rawContent.type = "agent_timeline"` so Memind can extract user and agent memories from the + same agent turn. +- **Retry**: failed timeline extraction payloads are spooled under `~/.memind/codex/retry/` and replayed on the next `SessionStart`. - **Source tagging**: all requests use `sourceClient = "codex"` by default, so Memind can distinguish Codex memory from Claude Code, OpenClaw, API calls, or future clients. @@ -121,11 +119,11 @@ The installed hooks are: | Codex event | Script | Timeout | Purpose | | --- | --- | ---: | --- | -| `SessionStart` | `scripts/session_start.py` | 5s | Replay at most one failed ingestion batch and clean old state. | +| `SessionStart` | `scripts/session_start.py` | 5s | Replay at most one failed timeline payload and clean old state. | | `UserPromptSubmit` | `scripts/retrieve.py` | 12s | Retrieve relevant Memind context for the current user prompt. | | `PreToolUse` | `scripts/pre_tool_use.py` | 5s | Buffer a redacted tool-start event in local session state. | | `PostToolUse` | `scripts/post_tool_use.py` | 5s | Buffer a redacted tool-result event in local session state. | -| `Stop` | `scripts/ingest.py` | 15s | Submit new Codex transcript messages through reliable extraction. | +| `Stop` | `scripts/ingest.py` | 15s | Flush buffered `agent_timeline` events after a turn. | ## Configuration @@ -142,8 +140,6 @@ User configuration is optional. Save overrides as `~/.memind/codex.json`: "agentIdMode": "project", "sourceClient": "codex", "autoIngestAgentTimeline": true, - "ingestionMode": "extract-sync", - "commitOnStop": false, "retrieveContextTurns": 0 } ``` @@ -165,16 +161,11 @@ Settings are loaded in this order: | `agentIdMode` | `project` | `project` appends a stable project suffix; any other value uses `agentId` as-is. | | `sourceClient` | `codex` | Source marker stored with Memind data. | | `autoRetrieve` | `true` | Enables prompt-time memory retrieval. | -| `autoIngest` | `true` | Enables transcript ingestion after Codex turns. | -| `autoIngestAgentTimeline` | `true` | Enables `PreToolUse`/`PostToolUse` event flush as `agent_timeline` raw data. | -| `commitOnStop` | `false` | Compatibility flag for server-buffer ingestion mode; ignored by the default reliable mode. | +| `autoIngestAgentTimeline` | `true` | Enables `PreToolUse`/`PostToolUse` event buffering and `agent_timeline` rawdata flush. | | `retrieveStrategy` | `SIMPLE` | Memind retrieval strategy. | | `retrieveMaxEntries` | `8` | Maximum formatted memory entries injected into Codex. | | `retrieveMaxChars` | `6000` | Maximum injected context characters. | | `retrieveContextTurns` | `0` | Number of recent transcript turns to include in the retrieval query. | -| `ingestionMode` | `extract-sync` | Default reliable ingestion mode. | -| `ingestionRoles` | `["user", "assistant"]` | Transcript roles eligible for ingestion. | -| `ingestionMaxMessagesPerHook` | `20` | Maximum new messages sent during one Stop hook. | | `ingestRetrySpool` | `true` | Enables file-backed retry for failed ingestion. | | `debug` | `false` | Writes debug logs to `~/.memind/codex.log`. | @@ -190,14 +181,13 @@ export MEMIND_AGENT_ID=codex export MEMIND_AGENT_ID_MODE=project export MEMIND_SOURCE_CLIENT=codex export MEMIND_AUTO_INGEST_AGENT_TIMELINE=true -export MEMIND_COMMIT_ON_STOP=false export MEMIND_RETRIEVE_CONTEXT_TURNS=0 export MEMIND_DEBUG=true ``` -Additional environment variables include `MEMIND_AUTO_RETRIEVE`, `MEMIND_AUTO_INGEST`, +Additional environment variables include `MEMIND_AUTO_RETRIEVE`, `MEMIND_RETRIEVE_STRATEGY`, `MEMIND_RETRIEVE_MAX_ENTRIES`, `MEMIND_RETRIEVE_MAX_CHARS`, -`MEMIND_INGESTION_ROLES`, `MEMIND_INGESTION_MAX_MESSAGES_PER_HOOK`, `MEMIND_STATE_MAX_AGE_DAYS`, +`MEMIND_STATE_MAX_AGE_DAYS`, `MEMIND_INGEST_RETRY_SPOOL`, `MEMIND_INGEST_RETRY_MAX_FILES`, and `MEMIND_INGEST_RETRY_MAX_AGE_DAYS`. ## Identity Model @@ -253,24 +243,9 @@ Agent memory items are grouped separately when returned by Memind: ## Ingestion Behavior -Ingestion runs after each Codex turn when `autoIngest = true`. - -The Stop hook: - -1. Reads the Codex transcript. -2. Extracts final user and assistant message text. -3. Strips previously injected `` blocks to avoid feedback loops. -4. Skips tool/event payloads and Codex control context blocks. -5. Computes stable fingerprints and sends only messages that have not already been submitted. -6. Builds one caller-owned conversation raw-content payload and submits it through - `AsyncMemindClient.memory.extract(...)`. - -The local retry spool stores the full extraction payload plus the covered message fingerprints. Fingerprints are -marked submitted only after Memind returns `SUCCESS`; `PARTIAL_SUCCESS` and failures keep the payload available -for later replay. - -Tool and command events are buffered under `~/.memind/codex/state/` and flushed with the same reliable extraction -path. A typical timeline payload looks like: +Ingestion is timeline-only for Codex. The plugin does not submit transcript conversation rawdata. It buffers tool +and command events under `~/.memind/codex/state/` and flushes them through +`AsyncMemindClient.memory.extract(...)` as agent timeline rawdata. A typical timeline payload looks like: ```json { @@ -303,8 +278,8 @@ path. A typical timeline payload looks like: Secrets are redacted before events are written to local state. File content capture is disabled by default; the hook stores normalized tool metadata, commands, paths, statuses, and compact outputs. -Commit flags apply only to explicit server-buffer mode. In the default reliable mode, hooks do not issue an -additional `/commit` call after successful `/extract/sync`. +On `SUCCESS`, the covered events are removed from local state. `PARTIAL_SUCCESS` and failures keep the events +available and spool the full timeline payload for later `SessionStart` replay. ## Server RawData Agent Settings @@ -421,26 +396,18 @@ curl -fsSL http://127.0.0.1:8366/open/v1/health - Confirm existing memories are stored under the same `userId` and `agentId`. - Try setting `retrieveContextTurns` to `1` or `2` if the current prompt is very short. -### Messages are not ingested +### Agent timeline events are not ingested -- Confirm `autoIngest` is `true`. -- Confirm Codex provides `transcript_path` in hook payloads. +- Confirm `autoIngestAgentTimeline` is `true`. +- Confirm Codex is emitting `PreToolUse` and `PostToolUse` hooks. - Confirm `~/.memind/codex/state/` is writable. - Enable `MEMIND_DEBUG=true` and inspect `~/.memind/codex.log`. ### Stop hook times out - Confirm Memind server responds quickly. -- Reduce `ingestionMaxMessagesPerHook`. -- Keep the default `extract-sync` mode and reduce batch size before increasing hook timeout. - -### Duplicate messages appear - -The integration uses per-session fingerprints stored under `~/.memind/codex/state/`. If duplicates appear: - -- Confirm the state directory is writable. -- Check whether Codex transcript identifiers changed across sessions. -- Remove stale local state only if you accept that old transcript messages may be re-submitted. +- Reduce tool output volume before it reaches the hook if your Codex setup allows it. +- Inspect `~/.memind/codex/retry/` for repeatedly failing timeline payloads. ## Limitations @@ -453,5 +420,3 @@ The integration uses per-session fingerprints stored under `~/.memind/codex/stat want one canonical coding-agent path should enable `rawdata-agent` for full agent timelines and keep `rawdata-toolcall` for pure legacy tool-call logs. - Retrieval quality depends on existing extracted Memind items and insights. -- `commitOnStop` applies only to compatibility server-buffer ingestion mode and is ignored by the default - reliable extraction mode. diff --git a/memind-integrations/codex/scripts/ingest.py b/memind-integrations/codex/scripts/ingest.py index 578e88f7..4a880242 100644 --- a/memind-integrations/codex/scripts/ingest.py +++ b/memind-integrations/codex/scripts/ingest.py @@ -25,7 +25,6 @@ from lib.client import MemindClient from lib.agent_timeline import build_timeline_payload from lib.config import load_config -from lib.content import extract_messages from lib.identity import resolve_identity from lib.logging_utils import debug_log from lib.retry import RetrySpool @@ -46,33 +45,6 @@ def retry_root(): return Path.home() / ".memind" / "codex" / "retry" -def _message_payload(raw_message): - return {key: value for key, value in raw_message.items() if key != "fingerprint"} - - -def _extract_payload(messages): - return { - "type": "conversation", - "messages": [_message_payload(message) for message in messages], - } - - -def _spool_extract(retry_spool, identity, source_client, session_key, messages): - if retry_spool is None or not messages: - return - retry_spool.enqueue( - { - "kind": "extract", - "userId": identity["userId"], - "agentId": identity["agentId"], - "sourceClient": source_client, - "sessionKey": session_key, - "fingerprints": [message["fingerprint"] for message in messages], - "rawContent": _extract_payload(messages), - } - ) - - def _spool_agent_timeline(retry_spool, identity, source_client, session_key, events, raw_content): if retry_spool is None or not events: return @@ -92,40 +64,16 @@ def _spool_agent_timeline(retry_spool, identity, source_client, session_key, eve async def ingest_messages_async(config, hook_input): identity = resolve_identity(config, hook_input) client = MemindClient(config["memindApiUrl"], config.get("memindApiToken"), timeout=10, max_retries=0) - transcript_path = hook_input.get("transcript_path") - messages = [] - if config.get("autoIngest", True) and transcript_path and Path(transcript_path).exists(): - messages = extract_messages(transcript_path, config.get("ingestionRoles", ["user", "assistant"])) - limit = int(config.get("ingestionMaxMessagesPerHook", 20)) retry_spool = RetrySpool(retry_root()) if config.get("ingestRetrySpool", True) else None store = SessionStateStore(state_root()) session_key = state_key(hook_input) source_client = config.get("sourceClient") + agent_events_submitted = 0 with store.locked(session_key) as state: - selected = [message for message in messages if not state.is_submitted(message["fingerprint"])][:limit] agent_events = state.agent_events() if config.get("autoIngestAgentTimeline", True) else [] - submitted = [] - if selected: - try: - response = await client.extract( - identity["userId"], - identity["agentId"], - _extract_payload(selected), - source_client, - ) - except Exception: - _spool_extract(retry_spool, identity, source_client, session_key, selected) - else: - status = getattr(response, "status", None) - if status == "SUCCESS": - submitted = [message["fingerprint"] for message in selected] - store.mark_submitted(session_key, submitted) - else: - _spool_extract(retry_spool, identity, source_client, session_key, selected) - if agent_events: timeline_payload = build_timeline_payload( config, @@ -153,6 +101,7 @@ async def ingest_messages_async(config, hook_input): else: status = getattr(response, "status", None) if status == "SUCCESS": + agent_events_submitted = len(agent_events) store.clear_agent_events( session_key, [event["eventId"] for event in agent_events if event.get("eventId")], @@ -167,7 +116,7 @@ async def ingest_messages_async(config, hook_input): timeline_payload, ) - return {"submitted": len(submitted), "committed": False} + return {"agentEventsSubmitted": agent_events_submitted, "committed": False} def ingest_messages(config, hook_input): diff --git a/memind-integrations/codex/scripts/lib/config.py b/memind-integrations/codex/scripts/lib/config.py index 0710b3c4..546f3465 100644 --- a/memind-integrations/codex/scripts/lib/config.py +++ b/memind-integrations/codex/scripts/lib/config.py @@ -24,17 +24,12 @@ "agentIdMode": "project", "sourceClient": "codex", "autoRetrieve": True, - "autoIngest": True, "autoIngestAgentTimeline": True, - "commitOnStop": False, "retrieveStrategy": "SIMPLE", "retrieveMaxEntries": 8, "retrieveMaxChars": 6000, "retrievePromptPreamble": "Relevant memories from Memind. Use only when directly helpful:", "retrieveContextTurns": 0, - "ingestionMode": "extract-sync", - "ingestionRoles": ["user", "assistant"], - "ingestionMaxMessagesPerHook": 20, "stateMaxAgeDays": 14, "ingestRetrySpool": True, "ingestRetryMaxFiles": 20, @@ -50,16 +45,11 @@ "MEMIND_AGENT_ID_MODE": ("agentIdMode", str), "MEMIND_SOURCE_CLIENT": ("sourceClient", str), "MEMIND_AUTO_RETRIEVE": ("autoRetrieve", "bool"), - "MEMIND_AUTO_INGEST": ("autoIngest", "bool"), "MEMIND_AUTO_INGEST_AGENT_TIMELINE": ("autoIngestAgentTimeline", "bool"), - "MEMIND_COMMIT_ON_STOP": ("commitOnStop", "bool"), "MEMIND_RETRIEVE_STRATEGY": ("retrieveStrategy", str), "MEMIND_RETRIEVE_MAX_ENTRIES": ("retrieveMaxEntries", "int"), "MEMIND_RETRIEVE_MAX_CHARS": ("retrieveMaxChars", "int"), "MEMIND_RETRIEVE_CONTEXT_TURNS": ("retrieveContextTurns", "int_allow_zero"), - "MEMIND_INGESTION_MODE": ("ingestionMode", str), - "MEMIND_INGESTION_ROLES": ("ingestionRoles", "list"), - "MEMIND_INGESTION_MAX_MESSAGES_PER_HOOK": ("ingestionMaxMessagesPerHook", "int"), "MEMIND_STATE_MAX_AGE_DAYS": ("stateMaxAgeDays", "int"), "MEMIND_INGEST_RETRY_SPOOL": ("ingestRetrySpool", "bool"), "MEMIND_INGEST_RETRY_MAX_FILES": ("ingestRetryMaxFiles", "int"), diff --git a/memind-integrations/codex/scripts/lib/content.py b/memind-integrations/codex/scripts/lib/content.py index 2d20d39e..67d6c8dd 100644 --- a/memind-integrations/codex/scripts/lib/content.py +++ b/memind-integrations/codex/scripts/lib/content.py @@ -12,7 +12,6 @@ # limitations under the License. # -import hashlib import json import re from pathlib import Path @@ -91,57 +90,11 @@ def _text_blocks(content): return [] -def _stable_id(entry): - payload = entry.get("payload") if isinstance(entry.get("payload"), dict) else {} - return entry.get("uuid") or entry.get("id") or payload.get("id") - - def _timestamp(entry): payload = entry.get("payload") if isinstance(entry.get("payload"), dict) else {} return entry.get("timestamp") or entry.get("created_at") or payload.get("timestamp") or payload.get("created_at") -def fingerprint_message(entry, role, text, line_index): - stable_id = _stable_id(entry) - if stable_id: - source = ("id", stable_id, role) - else: - source = ("fallback", role, text, _timestamp(entry), line_index) - serialized = json.dumps(source, sort_keys=True, ensure_ascii=False, separators=(",", ":")) - return hashlib.sha1(serialized.encode("utf-8")).hexdigest() - - -def extract_messages(path, roles): - allowed = {role.lower() for role in roles} - messages = [] - for line_index, entry in _parse_jsonl(path): - payload = _entry_payload(entry) - if not payload: - continue - role_text = str(payload.get("role", "")).lower() - if role_text not in allowed or role_text not in {"user", "assistant"}: - continue - if role_text == "assistant" and payload.get("phase") not in {None, "final_answer"}: - continue - texts = _text_blocks(payload.get("content")) - if not texts: - continue - role = "USER" if role_text == "user" else "ASSISTANT" - timestamp = payload.get("timestamp") or payload.get("created_at") or _timestamp(entry) - user_name = payload.get("user_name") or payload.get("userName") - for text in texts: - messages.append( - { - "fingerprint": fingerprint_message(entry, role, text, line_index), - "role": role, - "content": [{"type": "text", "text": text}], - "timestamp": timestamp, - "userName": user_name, - } - ) - return messages - - def _tail_lines(path, max_bytes=65536): path = Path(path) with path.open("rb") as handle: diff --git a/memind-integrations/codex/scripts/lib/state.py b/memind-integrations/codex/scripts/lib/state.py index 7156a1a2..2c71b0f8 100644 --- a/memind-integrations/codex/scripts/lib/state.py +++ b/memind-integrations/codex/scripts/lib/state.py @@ -36,9 +36,6 @@ def state_key(hook_input): session_id = hook_input.get("session_id") if session_id: return _safe_name(session_id) - transcript_path = hook_input.get("transcript_path") - if transcript_path: - return f"transcript-{_hash(str(Path(transcript_path).expanduser().resolve()))}" cwd = hook_input.get("cwd") if cwd: return f"cwd-{_hash(str(Path(cwd).expanduser().resolve()))}" @@ -89,19 +86,9 @@ def __exit__(self, exc_type, exc, tb): class SessionState: def __init__(self, data): self.data = data - self.data.setdefault("submitted", []) self.data.setdefault("agentEvents", []) self.data.setdefault("nextAgentSeq", 1) - def is_submitted(self, fingerprint): - return fingerprint in set(self.data.get("submitted", [])) - - def mark_submitted(self, fingerprints): - submitted = set(self.data.get("submitted", [])) - submitted.update(fingerprints) - self.data["submitted"] = sorted(submitted) - self.data["updatedAt"] = time.time() - def append_agent_event(self, event): events = list(self.data.get("agentEvents", [])) event_id = event.get("eventId") @@ -167,10 +154,6 @@ def locked(self, session_key): yield state self._write(path, state.data) - def mark_submitted(self, session_key, fingerprints): - with self.locked(session_key) as state: - state.mark_submitted(fingerprints) - def clear_agent_events(self, session_key, event_ids): with self.locked(session_key) as state: state.clear_agent_events(event_ids) diff --git a/memind-integrations/codex/scripts/session_start.py b/memind-integrations/codex/scripts/session_start.py index fa6fcaa3..a4e21d65 100644 --- a/memind-integrations/codex/scripts/session_start.py +++ b/memind-integrations/codex/scripts/session_start.py @@ -39,56 +39,26 @@ def _tcp_check(url, timeout=1): return True -async def _replay_ingestion_batch(client, payload): - session_key = payload.get("sessionKey") - fingerprints = payload.get("fingerprints") or [] - operations = payload.get("operations") or [] - store = SessionStateStore(state_root()) - appended = 0 - for index, operation in enumerate(operations): - if operation.get("kind") != "add-message": - continue - fingerprint = fingerprints[index] if index < len(fingerprints) else None - if fingerprint and session_key: - with store.locked(session_key) as state: - if state.is_submitted(fingerprint): - continue - await client.add_message( - operation["userId"], - operation["agentId"], - operation["message"], - operation.get("sourceClient"), - ) - appended += 1 - if fingerprint and session_key: - store.mark_submitted(session_key, [fingerprint]) - if appended and payload.get("commitOnSuccess"): - await client.commit(payload["userId"], payload["agentId"], payload.get("sourceClient")) - return appended - - async def _replay_payload(client, payload): kind = payload.get("kind") if kind == "extract": + raw_content = payload.get("rawContent") or {} + if raw_content.get("type") != "agent_timeline": + return 0 response = await client.extract( payload["userId"], payload["agentId"], - payload["rawContent"], + raw_content, payload.get("sourceClient"), ) status = getattr(response, "status", None) if status != "SUCCESS": raise RuntimeError(f"extract replay did not fully succeed: {status}") session_key = payload.get("sessionKey") - fingerprints = payload.get("fingerprints") or [] - if session_key and fingerprints: - SessionStateStore(state_root()).mark_submitted(session_key, fingerprints) event_ids = payload.get("eventIds") or [] if session_key and event_ids: SessionStateStore(state_root()).clear_agent_events(session_key, event_ids) - return len(fingerprints) - if kind == "ingestion-batch": - return await _replay_ingestion_batch(client, payload) + return 0 if kind == "commit": await client.commit(payload["userId"], payload["agentId"], payload.get("sourceClient")) return 0 diff --git a/memind-integrations/codex/settings.json b/memind-integrations/codex/settings.json index 1f1a4599..86273ac9 100644 --- a/memind-integrations/codex/settings.json +++ b/memind-integrations/codex/settings.json @@ -6,17 +6,12 @@ "agentIdMode": "project", "sourceClient": "codex", "autoRetrieve": true, - "autoIngest": true, "autoIngestAgentTimeline": true, - "commitOnStop": false, "retrieveStrategy": "SIMPLE", "retrieveMaxEntries": 8, "retrieveMaxChars": 6000, "retrievePromptPreamble": "Relevant memories from Memind. Use only when directly helpful:", "retrieveContextTurns": 0, - "ingestionMode": "extract-sync", - "ingestionRoles": ["user", "assistant"], - "ingestionMaxMessagesPerHook": 20, "stateMaxAgeDays": 14, "ingestRetrySpool": true, "ingestRetryMaxFiles": 20, diff --git a/memind-integrations/codex/tests/test_config.py b/memind-integrations/codex/tests/test_config.py index b8837047..e0a5afc4 100644 --- a/memind-integrations/codex/tests/test_config.py +++ b/memind-integrations/codex/tests/test_config.py @@ -25,18 +25,16 @@ def test_defaults_are_codex_specific(self): config = load_config(plugin_root=Path(__file__).resolve().parents[1], user_config_path="/missing", env={}) self.assertEqual(config["agentId"], "codex") self.assertEqual(config["sourceClient"], "codex") - self.assertFalse(config["commitOnStop"]) self.assertEqual(config["retrieveContextTurns"], 0) + self.assertNotIn("commitOnStop", config) def test_user_config_and_env_override_settings(self): with tempfile.TemporaryDirectory() as tmp: user_config = Path(tmp) / "codex.json" - user_config.write_text(json.dumps({"agentId": "custom", "commitOnStop": True})) + user_config.write_text(json.dumps({"agentId": "custom"})) env = { "MEMIND_API_URL": "http://example.test", - "MEMIND_COMMIT_ON_STOP": "false", "MEMIND_RETRIEVE_CONTEXT_TURNS": "2", - "MEMIND_INGESTION_ROLES": "user,assistant", } config = load_config( plugin_root=Path(__file__).resolve().parents[1], @@ -45,9 +43,9 @@ def test_user_config_and_env_override_settings(self): ) self.assertEqual(config["agentId"], "custom") self.assertEqual(config["memindApiUrl"], "http://example.test") - self.assertFalse(config["commitOnStop"]) self.assertEqual(config["retrieveContextTurns"], 2) - self.assertEqual(config["ingestionRoles"], ["user", "assistant"]) + self.assertNotIn("commitOnStop", config) + self.assertNotIn("ingestionRoles", config) def test_parse_helpers(self): self.assertTrue(parse_bool("yes")) diff --git a/memind-integrations/codex/tests/test_content.py b/memind-integrations/codex/tests/test_content.py index 3c4fca80..ed5e1376 100644 --- a/memind-integrations/codex/tests/test_content.py +++ b/memind-integrations/codex/tests/test_content.py @@ -17,7 +17,7 @@ import unittest from pathlib import Path -from scripts.lib.content import extract_messages, fingerprint_message, read_recent_context, strip_memind_blocks +from scripts.lib.content import read_recent_context, strip_memind_blocks class ContentTest(unittest.TestCase): @@ -33,7 +33,7 @@ def test_strip_memind_blocks(self): text = "before secret after" self.assertEqual(strip_memind_blocks(text), "before after") - def test_extract_messages_skips_codex_control_context_blocks(self): + def test_read_recent_context_skips_codex_control_context_blocks(self): path = self.write_jsonl( [ { @@ -48,13 +48,12 @@ def test_extract_messages_skips_codex_control_context_blocks(self): ] ) try: - messages = extract_messages(path, roles=["user", "assistant"]) + context = read_recent_context(path, turns=2) finally: path.unlink() - self.assertEqual(len(messages), 1) - self.assertEqual(messages[0]["content"][0]["text"], "real prompt") + self.assertEqual(context, "user: real prompt") - def test_extracts_codex_response_item_messages(self): + def test_read_recent_context_reads_codex_response_item_messages(self): path = self.write_jsonl( [ { @@ -82,13 +81,10 @@ def test_extracts_codex_response_item_messages(self): ] ) try: - messages = extract_messages(path, roles=["user", "assistant"]) + context = read_recent_context(path, turns=2) finally: path.unlink() - self.assertEqual([m["role"] for m in messages], ["USER", "ASSISTANT"]) - self.assertEqual(messages[0]["content"][0]["text"], "hello") - self.assertEqual(messages[1]["content"][0]["text"], "answer\n\nmore") - self.assertIn("fingerprint", messages[0]) + self.assertEqual(context, "user: hello\nassistant: answer\n\nmore") def test_skips_non_final_assistant_and_tool_calls(self): path = self.write_jsonl( @@ -109,22 +105,10 @@ def test_skips_non_final_assistant_and_tool_calls(self): ] ) try: - messages = extract_messages(path, roles=["user", "assistant"]) + context = read_recent_context(path, turns=2) finally: path.unlink() - self.assertEqual(len(messages), 1) - self.assertEqual(messages[0]["content"][0]["text"], "visible") - - def test_fingerprint_prefers_stable_id(self): - first = fingerprint_message({"payload": {"id": "m1"}, "type": "response_item"}, "USER", "same", 1) - second = fingerprint_message({"payload": {"id": "m1"}, "type": "response_item"}, "USER", "same", 99) - self.assertEqual(first, second) - - def test_fingerprint_uses_line_index_to_keep_repeated_messages_distinct(self): - entry = {"type": "response_item", "payload": {"type": "message"}} - first = fingerprint_message(entry, "USER", "ok", 1) - second = fingerprint_message(entry, "USER", "ok", 2) - self.assertNotEqual(first, second) + self.assertEqual(context, "user: visible") def test_read_recent_context_reads_tail(self): entries = [{"role": "user", "content": f"message-{i}"} for i in range(20)] diff --git a/memind-integrations/codex/tests/test_hooks.py b/memind-integrations/codex/tests/test_hooks.py index c47ee1f3..cc167950 100644 --- a/memind-integrations/codex/tests/test_hooks.py +++ b/memind-integrations/codex/tests/test_hooks.py @@ -184,7 +184,7 @@ def test_post_tool_use_fails_open_and_buffers_event(self): self.assertEqual(event["kind"], "command") self.assertEqual(event["status"], "success") - def test_ingest_uses_extract_sync_and_ignores_commit_flag_in_reliable_mode(self): + def test_ingest_ignores_transcript_when_no_agent_events(self): sys.path.insert(0, str(ROOT / "scripts")) import ingest @@ -195,15 +195,12 @@ def test_ingest_uses_extract_sync_and_ignores_commit_flag_in_reliable_mode(self) config = { "memindApiUrl": "http://127.0.0.1:8366", "memindApiToken": None, - "autoIngest": True, - "ingestionRoles": ["user", "assistant"], - "ingestionMaxMessagesPerHook": 20, + "autoIngestAgentTimeline": True, "ingestRetrySpool": False, "sourceClient": "codex", "agentId": "codex", "agentIdMode": "global", "userId": "u", - "commitOnStop": True, } with tempfile.TemporaryDirectory() as tmp: with mock.patch.object(ingest, "state_root", return_value=Path(tmp) / "state"): @@ -214,146 +211,14 @@ def test_ingest_uses_extract_sync_and_ignores_commit_flag_in_reliable_mode(self) client.add_message = mock.AsyncMock(return_value=None) client.commit = mock.AsyncMock(return_value=None) result = ingest.ingest_messages(config, {"session_id": "s1", "transcript_path": str(transcript), "cwd": tmp}) - self.assertEqual(result["submitted"], 1) + self.assertEqual(result["agentEventsSubmitted"], 0) self.assertFalse(result["committed"]) - client.extract.assert_awaited_once() - client.add_message.assert_not_awaited() - client.commit.assert_not_awaited() - finally: - transcript.unlink() - - def test_ingest_does_not_mark_submitted_or_commit_when_extract_fails_without_spool(self): - sys.path.insert(0, str(ROOT / "scripts")) - import ingest - from scripts.lib.content import extract_messages - from scripts.lib.state import SessionStateStore - - with tempfile.NamedTemporaryFile("w", delete=False) as handle: - handle.write(json.dumps({"role": "user", "content": "first"}) + "\n") - handle.write(json.dumps({"role": "assistant", "content": "second"}) + "\n") - transcript = Path(handle.name) - try: - config = { - "memindApiUrl": "http://127.0.0.1:8366", - "memindApiToken": None, - "autoIngest": True, - "ingestionRoles": ["user", "assistant"], - "ingestionMaxMessagesPerHook": 20, - "ingestRetrySpool": False, - "sourceClient": "codex", - "agentId": "codex", - "agentIdMode": "global", - "userId": "u", - "commitOnStop": True, - } - with tempfile.TemporaryDirectory() as tmp: - with mock.patch.object(ingest, "state_root", return_value=Path(tmp) / "state"): - with mock.patch.object(ingest, "retry_root", return_value=Path(tmp) / "retry"): - with mock.patch.object(ingest, "MemindClient") as client_cls: - client = client_cls.return_value - client.extract = mock.AsyncMock(side_effect=RuntimeError("down")) - client.add_message = mock.AsyncMock(return_value=None) - client.commit = mock.AsyncMock(return_value=None) - result = ingest.ingest_messages(config, {"session_id": "s1", "transcript_path": str(transcript), "cwd": tmp}) - messages = extract_messages(transcript, ["user", "assistant"]) - with SessionStateStore(Path(tmp) / "state").locked("s1") as state: - self.assertFalse(state.is_submitted(messages[0]["fingerprint"])) - self.assertFalse(state.is_submitted(messages[1]["fingerprint"])) - self.assertEqual(result["submitted"], 0) - self.assertFalse(result["committed"]) - client.extract.assert_awaited_once() + client.extract.assert_not_awaited() client.add_message.assert_not_awaited() client.commit.assert_not_awaited() finally: transcript.unlink() - def test_ingest_spools_failed_extract_payload(self): - sys.path.insert(0, str(ROOT / "scripts")) - import ingest - - with tempfile.NamedTemporaryFile("w", delete=False) as handle: - handle.write(json.dumps({"role": "user", "content": "first"}) + "\n") - handle.write(json.dumps({"role": "assistant", "content": "second"}) + "\n") - transcript = Path(handle.name) - try: - config = { - "memindApiUrl": "http://127.0.0.1:8366", - "memindApiToken": None, - "autoIngest": True, - "ingestionRoles": ["user", "assistant"], - "ingestionMaxMessagesPerHook": 20, - "ingestRetrySpool": True, - "sourceClient": "codex", - "agentId": "codex", - "agentIdMode": "global", - "userId": "u", - "commitOnStop": True, - } - with tempfile.TemporaryDirectory() as tmp: - retry_dir = Path(tmp) / "retry" - with mock.patch.object(ingest, "state_root", return_value=Path(tmp) / "state"): - with mock.patch.object(ingest, "retry_root", return_value=retry_dir): - with mock.patch.object(ingest, "MemindClient") as client_cls: - client = client_cls.return_value - client.extract = mock.AsyncMock(side_effect=RuntimeError("down")) - client.add_message = mock.AsyncMock(return_value=None) - client.commit = mock.AsyncMock(return_value=None) - result = ingest.ingest_messages(config, {"session_id": "s1", "transcript_path": str(transcript), "cwd": tmp}) - payload_files = list(retry_dir.glob("*.json")) - self.assertEqual(len(payload_files), 1) - payload = json.loads(payload_files[0].read_text()) - self.assertEqual(result["submitted"], 0) - self.assertFalse(result["committed"]) - self.assertEqual(payload["kind"], "extract") - self.assertEqual(payload["rawContent"]["type"], "conversation") - self.assertEqual(len(payload["rawContent"]["messages"]), 2) - self.assertEqual(len(payload["fingerprints"]), 2) - self.assertNotIn("commitOnSuccess", payload) - client.extract.assert_awaited_once() - client.add_message.assert_not_awaited() - client.commit.assert_not_awaited() - finally: - transcript.unlink() - - def test_ingest_spools_full_extract_payload_on_partial_success(self): - sys.path.insert(0, str(ROOT / "scripts")) - import ingest - - with tempfile.NamedTemporaryFile("w", delete=False) as handle: - handle.write(json.dumps({"role": "user", "content": "first"}) + "\n") - transcript = Path(handle.name) - try: - config = { - "memindApiUrl": "http://127.0.0.1:8366", - "memindApiToken": None, - "autoIngest": True, - "ingestionRoles": ["user", "assistant"], - "ingestionMaxMessagesPerHook": 20, - "ingestRetrySpool": True, - "sourceClient": "codex", - "agentId": "codex", - "agentIdMode": "global", - "userId": "u", - "commitOnStop": False, - } - with tempfile.TemporaryDirectory() as tmp: - retry_dir = Path(tmp) / "retry" - with mock.patch.object(ingest, "state_root", return_value=Path(tmp) / "state"): - with mock.patch.object(ingest, "retry_root", return_value=retry_dir): - with mock.patch.object(ingest, "MemindClient") as client_cls: - client = client_cls.return_value - client.extract = mock.AsyncMock(return_value=types.SimpleNamespace(status="PARTIAL_SUCCESS")) - client.add_message = mock.AsyncMock(return_value=None) - client.commit = mock.AsyncMock(return_value=None) - result = ingest.ingest_messages(config, {"session_id": "s1", "transcript_path": str(transcript), "cwd": tmp}) - payload = json.loads(next(retry_dir.glob("*.json")).read_text()) - self.assertEqual(result["submitted"], 0) - self.assertEqual(payload["kind"], "extract") - self.assertEqual(payload["rawContent"]["type"], "conversation") - self.assertEqual(len(payload["fingerprints"]), 1) - finally: - transcript.unlink() - def test_ingest_flushes_agent_timeline_and_clears_events_on_success(self): sys.path.insert(0, str(ROOT / "scripts")) import ingest @@ -362,7 +227,6 @@ def test_ingest_flushes_agent_timeline_and_clears_events_on_success(self): config = { "memindApiUrl": "http://127.0.0.1:8366", "memindApiToken": None, - "autoIngest": False, "autoIngestAgentTimeline": True, "ingestRetrySpool": True, "sourceClient": "codex", @@ -382,7 +246,7 @@ def test_ingest_flushes_agent_timeline_and_clears_events_on_success(self): client = client_cls.return_value client.extract = mock.AsyncMock(return_value=types.SimpleNamespace(status="SUCCESS")) result = ingest.ingest_messages(config, {"session_id": "s1", "cwd": tmp}) - self.assertEqual(result["submitted"], 0) + self.assertEqual(result["agentEventsSubmitted"], 1) client.extract.assert_awaited_once() raw_content = client.extract.await_args.args[2] self.assertEqual(raw_content["type"], "agent_timeline") @@ -399,7 +263,6 @@ def test_ingest_spools_agent_timeline_on_partial_success(self): config = { "memindApiUrl": "http://127.0.0.1:8366", "memindApiToken": None, - "autoIngest": False, "autoIngestAgentTimeline": True, "ingestRetrySpool": True, "sourceClient": "codex", @@ -441,7 +304,7 @@ def test_session_start_fail_open_when_memind_unavailable(self): output = self.run_hook("session_start.py", {"cwd": tmp, "session_id": "s1"}, env=env) self.assertEqual(output, {"continue": True, "suppressOutput": True}) - def test_session_start_replays_ingestion_batch_before_commit(self): + def test_session_start_discards_unsupported_retry_payload(self): sys.path.insert(0, str(ROOT / "scripts")) import session_start from scripts.lib.retry import RetrySpool @@ -451,22 +314,11 @@ def test_session_start_replays_ingestion_batch_before_commit(self): state_root = Path(tmp) / "state" RetrySpool(retry_root).enqueue( { - "kind": "ingestion-batch", + "kind": "unsupported", "userId": "u", "agentId": "a", "sourceClient": "codex", - "operations": [ - { - "kind": "add-message", - "userId": "u", - "agentId": "a", - "sourceClient": "codex", - "message": {"role": "USER", "content": [{"type": "text", "text": "hello"}]}, - } - ], "sessionKey": "s1", - "fingerprints": ["fp1"], - "commitOnSuccess": True, } ) config = { @@ -481,25 +333,18 @@ def test_session_start_replays_ingestion_batch_before_commit(self): with mock.patch.object(session_start, "state_root", return_value=state_root): with mock.patch.object(session_start, "MemindClient") as client_cls: client = client_cls.return_value - events = [] - - async def add_message_side_effect(*args, **kwargs): - events.append("add_message") - - async def commit_side_effect(*args, **kwargs): - events.append("commit") - client.health = mock.AsyncMock(return_value=types.SimpleNamespace(status="UP")) - client.add_message = mock.AsyncMock(side_effect=add_message_side_effect) - client.commit = mock.AsyncMock(side_effect=commit_side_effect) + client.add_message = mock.AsyncMock(return_value=None) + client.commit = mock.AsyncMock(return_value=None) session_start.run_session_start(config) - self.assertEqual(events, ["add_message", "commit"]) + self.assertEqual(list(retry_root.glob("*.json")), []) + client.add_message.assert_not_awaited() + client.commit.assert_not_awaited() - def test_session_start_replays_extract_payload_and_marks_fingerprints(self): + def test_session_start_discards_legacy_conversation_extract_payload(self): sys.path.insert(0, str(ROOT / "scripts")) import session_start from scripts.lib.retry import RetrySpool - from scripts.lib.state import SessionStateStore with tempfile.TemporaryDirectory() as tmp: retry_root = Path(tmp) / "retry" @@ -511,7 +356,6 @@ def test_session_start_replays_extract_payload_and_marks_fingerprints(self): "agentId": "a", "sourceClient": "codex", "sessionKey": "s1", - "fingerprints": ["fp1"], "rawContent": { "type": "conversation", "messages": [ @@ -537,60 +381,11 @@ def test_session_start_replays_extract_payload_and_marks_fingerprints(self): client.add_message = mock.AsyncMock(return_value=None) client.commit = mock.AsyncMock(return_value=None) session_start.run_session_start(config) - with SessionStateStore(state_root).locked("s1") as state: - self.assertTrue(state.is_submitted("fp1")) self.assertEqual(list(retry_root.glob("*.json")), []) - client.extract.assert_awaited_once() + client.extract.assert_not_awaited() client.add_message.assert_not_awaited() client.commit.assert_not_awaited() - def test_session_start_keeps_extract_payload_when_replay_is_not_success(self): - sys.path.insert(0, str(ROOT / "scripts")) - import session_start - from scripts.lib.retry import RetrySpool - from scripts.lib.state import SessionStateStore - - with tempfile.TemporaryDirectory() as tmp: - retry_root = Path(tmp) / "retry" - state_root = Path(tmp) / "state" - RetrySpool(retry_root).enqueue( - { - "kind": "extract", - "userId": "u", - "agentId": "a", - "sourceClient": "codex", - "sessionKey": "s1", - "fingerprints": ["fp1"], - "rawContent": { - "type": "conversation", - "messages": [ - {"role": "USER", "content": [{"type": "text", "text": "hello"}]} - ], - }, - } - ) - config = { - "memindApiUrl": "http://127.0.0.1:8366", - "memindApiToken": None, - "ingestRetryMaxFiles": 20, - "ingestRetryMaxAgeDays": 7, - "stateMaxAgeDays": 14, - "debug": False, - } - with mock.patch.object(session_start, "retry_root", return_value=retry_root): - with mock.patch.object(session_start, "state_root", return_value=state_root): - with mock.patch.object(session_start, "MemindClient") as client_cls: - client = client_cls.return_value - client.health = mock.AsyncMock(return_value=types.SimpleNamespace(status="UP")) - client.extract = mock.AsyncMock(return_value=types.SimpleNamespace(status="PARTIAL_SUCCESS")) - client.add_message = mock.AsyncMock(return_value=None) - client.commit = mock.AsyncMock(return_value=None) - session_start.run_session_start(config) - with SessionStateStore(state_root).locked("s1") as state: - self.assertFalse(state.is_submitted("fp1")) - self.assertEqual(len(list(retry_root.glob("*.json"))), 1) - client.extract.assert_awaited_once() - def test_session_start_replays_agent_timeline_payload_and_clears_event_ids(self): sys.path.insert(0, str(ROOT / "scripts")) import session_start @@ -645,55 +440,6 @@ def test_session_start_replays_agent_timeline_payload_and_clears_event_ids(self) self.assertEqual(list(retry_root.glob("*.json")), []) client.extract.assert_awaited_once() - def test_session_start_skips_replayed_message_already_submitted_by_later_stop(self): - sys.path.insert(0, str(ROOT / "scripts")) - import session_start - from scripts.lib.retry import RetrySpool - from scripts.lib.state import SessionStateStore - - with tempfile.TemporaryDirectory() as tmp: - retry_root = Path(tmp) / "retry" - state_root = Path(tmp) / "state" - SessionStateStore(state_root).mark_submitted("s1", ["fp1"]) - RetrySpool(retry_root).enqueue( - { - "kind": "ingestion-batch", - "userId": "u", - "agentId": "a", - "sourceClient": "codex", - "operations": [ - { - "kind": "add-message", - "userId": "u", - "agentId": "a", - "sourceClient": "codex", - "message": {"role": "USER", "content": [{"type": "text", "text": "hello"}]}, - } - ], - "sessionKey": "s1", - "fingerprints": ["fp1"], - "commitOnSuccess": True, - } - ) - config = { - "memindApiUrl": "http://127.0.0.1:8366", - "memindApiToken": None, - "ingestRetryMaxFiles": 20, - "ingestRetryMaxAgeDays": 7, - "stateMaxAgeDays": 14, - "debug": False, - } - with mock.patch.object(session_start, "retry_root", return_value=retry_root): - with mock.patch.object(session_start, "state_root", return_value=state_root): - with mock.patch.object(session_start, "MemindClient") as client_cls: - client = client_cls.return_value - client.health = mock.AsyncMock(return_value=types.SimpleNamespace(status="UP")) - client.add_message = mock.AsyncMock(return_value=None) - client.commit = mock.AsyncMock(return_value=None) - session_start.run_session_start(config) - client.add_message.assert_not_awaited() - client.commit.assert_not_awaited() - if __name__ == "__main__": unittest.main() diff --git a/memind-integrations/codex/tests/test_manifest.py b/memind-integrations/codex/tests/test_manifest.py index 5059018c..acd4b4de 100644 --- a/memind-integrations/codex/tests/test_manifest.py +++ b/memind-integrations/codex/tests/test_manifest.py @@ -47,11 +47,12 @@ def test_default_settings_match_spec(self): settings = json.loads((ROOT / "settings.json").read_text()) self.assertEqual(settings["agentId"], "codex") self.assertEqual(settings["sourceClient"], "codex") - self.assertFalse(settings["commitOnStop"]) self.assertTrue(settings["autoIngestAgentTimeline"]) self.assertEqual(settings["retrieveContextTurns"], 0) - self.assertEqual(settings["ingestionMode"], "extract-sync") - self.assertEqual(settings["ingestionMaxMessagesPerHook"], 20) + self.assertNotIn("commitOnStop", settings) + self.assertNotIn("autoIngest", settings) + self.assertNotIn("ingestionRoles", settings) + self.assertNotIn("ingestionMaxMessagesPerHook", settings) self.assertEqual(settings["stateMaxAgeDays"], 14) diff --git a/memind-integrations/codex/tests/test_retry.py b/memind-integrations/codex/tests/test_retry.py index ee7cfe88..316c8f77 100644 --- a/memind-integrations/codex/tests/test_retry.py +++ b/memind-integrations/codex/tests/test_retry.py @@ -34,7 +34,7 @@ def test_enqueue_claim_complete(self): def test_release_returns_claim_to_json(self): with tempfile.TemporaryDirectory() as tmp: spool = RetrySpool(Path(tmp)) - spool.enqueue({"kind": "ingestion-batch", "operations": []}) + spool.enqueue({"kind": "extract", "rawContent": {"type": "agent_timeline"}}) claimed = spool.claim_next() spool.release(claimed) self.assertEqual(len(list(Path(tmp).glob("*.json"))), 1) diff --git a/memind-integrations/codex/tests/test_state.py b/memind-integrations/codex/tests/test_state.py index 4779cc53..fb6b4f1e 100644 --- a/memind-integrations/codex/tests/test_state.py +++ b/memind-integrations/codex/tests/test_state.py @@ -25,25 +25,9 @@ class StateTest(unittest.TestCase): def test_state_key_prefers_session_id(self): self.assertEqual(state_key({"session_id": "abc/def"}), "abc_def") - def test_state_key_falls_back_to_transcript_path(self): - key = state_key({"transcript_path": "/tmp/codex/transcript.jsonl"}) - self.assertTrue(key.startswith("transcript-")) - - def test_marks_and_loads_submitted_fingerprints(self): - with tempfile.TemporaryDirectory() as tmp: - store = SessionStateStore(Path(tmp)) - with store.locked("session-1") as state: - state.mark_submitted(["a", "b"]) - with store.locked("session-1") as state: - self.assertTrue(state.is_submitted("a")) - self.assertFalse(state.is_submitted("c")) - - def test_mark_submitted_persists_immediately(self): - with tempfile.TemporaryDirectory() as tmp: - store = SessionStateStore(Path(tmp)) - store.mark_submitted("session-1", ["a"]) - with store.locked("session-1") as state: - self.assertTrue(state.is_submitted("a")) + def test_state_key_falls_back_to_cwd(self): + key = state_key({"transcript_path": "/tmp/codex/transcript.jsonl", "cwd": "/tmp/project"}) + self.assertTrue(key.startswith("cwd-")) def test_agent_events_are_deduplicated_and_clear_by_event_id(self): with tempfile.TemporaryDirectory() as tmp: @@ -75,7 +59,7 @@ def test_cleanup_removes_old_state(self): root = Path(tmp) store = SessionStateStore(root) with store.locked("old") as state: - state.mark_submitted(["x"]) + state.append_agent_event({"eventId": "e1", "seq": 1}) old_file = root / "old.json" old_time = time.time() - 30 * 86400 os.utime(old_file, (old_time, old_time)) diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java index 10eb6858..403b9cb3 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java @@ -28,6 +28,7 @@ import com.openmemind.ai.memory.plugin.rawdata.agent.config.AgentExtractionOptions; import java.time.Instant; import java.util.ArrayList; +import java.util.EnumSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -120,9 +121,12 @@ private List enabledCategories(ParsedSegment segment, ItemExtractionConf Set allowed = config == null || config.allowedCategories() == null - ? MemoryCategory.agentCategories() + ? EnumSet.allOf(MemoryCategory.class) : config.allowedCategories(); var categories = new ArrayList(); + addIfEnabled(categories, allowed, MemoryCategory.PROFILE, true); + addIfEnabled(categories, allowed, MemoryCategory.BEHAVIOR, true); + addIfEnabled(categories, allowed, MemoryCategory.EVENT, true); addIfEnabled(categories, allowed, MemoryCategory.TOOL, options.extractTool()); addIfEnabled(categories, allowed, MemoryCategory.RESOLUTION, options.extractResolution()); addIfEnabled( @@ -189,7 +193,7 @@ private static ExtractedMemoryEntry toEntry( observedAt(segment), segment.rawDataId(), null, - List.of(expectedInsightType(item.category())), + insightTypes(item), metadata(segment, item), MemoryItemType.FACT, normalize(item.category()), @@ -207,8 +211,10 @@ private static boolean isValidItem( if (!categories.contains(category)) { return false; } - String expectedInsightType = expectedInsightType(category); - if (item.insightTypes() == null || !item.insightTypes().contains(expectedInsightType)) { + List expectedInsightTypes = expectedInsightTypes(category); + if (expectedInsightTypes.isEmpty() + || item.insightTypes() == null + || item.insightTypes().stream().noneMatch(expectedInsightTypes::contains)) { return false; } List evidenceEventIds = evidenceEventIds(item.metadata()); @@ -269,13 +275,21 @@ private static List evidenceEventIds(Map metadata) { return metadata == null ? List.of() : stringList(metadata.get("evidenceEventIds")); } - private static String expectedInsightType(String category) { + private static List insightTypes(MemoryItemExtractionResponse.ExtractedItem item) { + List expected = expectedInsightTypes(item.category()); + return item.insightTypes().stream().filter(expected::contains).distinct().toList(); + } + + private static List expectedInsightTypes(String category) { return switch (normalize(category)) { - case "tool" -> "tools"; - case "resolution" -> "resolutions"; - case "playbook" -> "playbooks"; - case "directive" -> "directives"; - default -> ""; + case "profile" -> List.of("identity", "preferences", "relationships"); + case "behavior" -> List.of("behavior"); + case "event" -> List.of("experiences"); + case "tool" -> List.of("tools"); + case "resolution" -> List.of("resolutions"); + case "playbook" -> List.of("playbooks"); + case "directive" -> List.of("directives"); + default -> List.of(); }; } diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemPrompts.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemPrompts.java index 2180e578..446b150f 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemPrompts.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemPrompts.java @@ -25,12 +25,11 @@ public final class AgentItemPrompts { private static final String SYSTEM = """ - You extract durable AGENT-scope memory items from one deterministic coding-agent \ + You extract durable memory items from one deterministic coding-agent \ episode. The episode has already been parsed from raw agent events; do not invent \ events, tools, files, or outcomes that are not present in the input. Categories are limited to: {{categories}}. - Do not emit user-scope categories such as profile, behavior, or event. Evidence rules: - Every item must include metadata.evidenceEventIds. @@ -38,6 +37,9 @@ public final class AgentItemPrompts { - Prefer the smallest evidence set that proves the memory. Category rules: + - profile: stable facts or enduring preferences about the user. + - behavior: recurring user habits or repeated collaboration/work patterns. + - event: time-bound user/project situations, current work, decisions, or milestones. - tool: concrete command or tool usage knowledge grounded in observed tool events. - resolution: resolved problem knowledge only; metadata must include problem and \ fix or conclusion. @@ -59,12 +61,14 @@ public final class AgentItemPrompts { "content": "durable memory sentence", "confidence": 0.0, "occurredAt": null, - "insightTypes": ["tools|resolutions|playbooks|directives"], + "insightTypes": [ + "identity|preferences|relationships|behavior|experiences|tools|resolutions|playbooks|directives" + ], "metadata": { "evidenceEventIds": ["event-id"], "...": "category-specific fields" }, - "category": "tool|resolution|playbook|directive", + "category": "profile|behavior|event|tool|resolution|playbook|directive", "entities": [ {"name": "entity", "entityType": "object", "salience": 0.8} ], diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/processor/AgentTimelineContentProcessor.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/processor/AgentTimelineContentProcessor.java index c9461bc0..f067a7b0 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/processor/AgentTimelineContentProcessor.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/processor/AgentTimelineContentProcessor.java @@ -21,6 +21,7 @@ import com.openmemind.ai.memory.plugin.rawdata.agent.caption.AgentCaptionGenerator; import com.openmemind.ai.memory.plugin.rawdata.agent.chunk.AgentTimelineChunker; import com.openmemind.ai.memory.plugin.rawdata.agent.content.AgentTimelineContent; +import java.util.EnumSet; import java.util.List; import java.util.Objects; import java.util.Set; @@ -79,7 +80,7 @@ public ItemExtractionStrategy itemExtractionStrategy() { @Override public Set allowedCategories() { - return MemoryCategory.agentCategories(); + return EnumSet.allOf(MemoryCategory.class); } @Override diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/integration/AgentExtractionPipelineIntegrationTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/integration/AgentExtractionPipelineIntegrationTest.java index 9af53bbf..46a3f1a1 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/integration/AgentExtractionPipelineIntegrationTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/integration/AgentExtractionPipelineIntegrationTest.java @@ -181,7 +181,7 @@ void failedUnresolvedEpisodeDoesNotProducePlaybook() { } @Test - void agentPipelineStoresAgentCategoriesOnly() { + void agentPipelineAllowsUserAndAgentCategoriesFromTimeline() { var client = new ScriptedStructuredChatClient( response( @@ -198,11 +198,17 @@ void agentPipelineStoresAgentCategoriesOnly() { assertThat(items(fixture)).isNotEmpty(); assertThat(items(fixture)) - .allSatisfy(item -> assertThat(item.scope()).isEqualTo(MemoryScope.AGENT)); + .anySatisfy( + item -> { + assertThat(item.category()).isEqualTo(MemoryCategory.PROFILE); + assertThat(item.scope()).isEqualTo(MemoryScope.USER); + assertThat(item.metadata().get("insightTypes")) + .asList() + .containsExactly("preferences"); + }); assertThat(items(fixture)) .extracting(MemoryItem::category) - .doesNotContain( - MemoryCategory.PROFILE, MemoryCategory.BEHAVIOR, MemoryCategory.EVENT); + .contains(MemoryCategory.TOOL, MemoryCategory.RESOLUTION, MemoryCategory.PROFILE); } @Test diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyLlmTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyLlmTest.java index e57204b5..62d3889d 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyLlmTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyLlmTest.java @@ -172,6 +172,47 @@ void shouldDropInvalidLlmItems() { .isEqualTo(1); } + @Test + void shouldAllowUserScopeMemoryCategoriesFromAgentTimeline() { + var client = + new StubStructuredChatClient( + response( + new MemoryItemExtractionResponse.ExtractedItem( + "User is currently refining Memind rawdata-agent" + + " extraction.", + 0.88f, + null, + List.of("experiences"), + Map.of("evidenceEventIds", List.of("e1")), + "event"))); + AgentItemExtractionStrategy strategy = strategy(client); + + List entries = + strategy.extract( + List.of(successfulEpisode()), + DefaultInsightTypes.all(), + allScopeConfig()) + .block(); + + assertThat(client.calls()).isEqualTo(1); + assertThat(client.lastMessages()) + .anySatisfy( + message -> + assertThat(message.content()) + .contains("event") + .contains("profile: stable facts") + .contains("event: time-bound user/project situations")); + assertThat(entries) + .anySatisfy( + entry -> { + assertThat(entry.category()).isEqualTo("event"); + assertThat(entry.insightTypes()).containsExactly("experiences"); + assertThat(entry.metadata().get("evidenceEventIds")) + .asList() + .containsExactly("e1"); + }); + } + @Test void shouldSkipLlmWhenEpisodeDoesNotMeetMinimumEventThreshold() { var client = @@ -294,6 +335,15 @@ private static ItemExtractionConfig agentConfig() { "en"); } + private static ItemExtractionConfig allScopeConfig() { + return new ItemExtractionConfig( + MemoryScope.USER, + AgentTimelineContent.TYPE, + java.util.EnumSet.allOf(MemoryCategory.class), + false, + "en"); + } + private static final class StubStructuredChatClient implements StructuredChatClient { private final MemoryItemExtractionResponse response; diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/processor/AgentTimelineContentProcessorTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/processor/AgentTimelineContentProcessorTest.java index 8c38f3ce..9308183b 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/processor/AgentTimelineContentProcessorTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/processor/AgentTimelineContentProcessorTest.java @@ -35,7 +35,7 @@ void shouldExposeAgentTimelineProcessorContract() { assertThat(processor.contentClass()).isEqualTo(AgentTimelineContent.class); assertThat(processor.contentType()).isEqualTo(AgentTimelineContent.TYPE); assertThat(processor.allowedCategories()) - .containsExactlyInAnyOrderElementsOf(MemoryCategory.agentCategories()); + .containsExactlyInAnyOrder(MemoryCategory.values()); assertThat(processor.usesSourceIdentity()).isTrue(); assertThat(processor.supportsInsight()).isTrue(); assertThat(processor.itemExtractionStrategy()) From 19808aa407d58266ad79235886b2cc67412046bb Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Tue, 26 May 2026 00:51:41 +0800 Subject: [PATCH 23/54] Capture full agent turn timelines --- memind-integrations/claude-code/README.md | 40 ++++++-- .../claude-code/scripts/ingest.py | 39 +++++++- .../claude-code/scripts/lib/agent_timeline.py | 96 +++++++++++++++++-- .../claude-code/scripts/lib/content.py | 16 ++++ .../claude-code/scripts/lib/state.py | 30 ++++++ .../claude-code/scripts/post_tool_use.py | 3 +- .../claude-code/scripts/pre_tool_use.py | 3 +- .../claude-code/scripts/retrieve.py | 15 ++- .../claude-code/tests/test_agent_timeline.py | 69 ++++++++++++- .../claude-code/tests/test_hooks.py | 69 ++++++++++++- memind-integrations/codex/README.md | 42 ++++++-- memind-integrations/codex/scripts/ingest.py | 47 +++++++-- .../codex/scripts/lib/agent_timeline.py | 92 +++++++++++++++++- .../codex/scripts/lib/content.py | 14 +++ .../codex/scripts/lib/state.py | 30 ++++++ .../codex/scripts/post_tool_use.py | 3 +- .../codex/scripts/pre_tool_use.py | 3 +- memind-integrations/codex/scripts/retrieve.py | 15 ++- .../codex/tests/test_agent_timeline.py | 72 +++++++++++++- memind-integrations/codex/tests/test_hooks.py | 80 +++++++++++++++- .../agent/chunk/AgentEpisodeAssembler.java | 27 +++--- .../chunk/AgentEpisodeAssemblerTest.java | 52 ++++++++++ 22 files changed, 793 insertions(+), 64 deletions(-) diff --git a/memind-integrations/claude-code/README.md b/memind-integrations/claude-code/README.md index b0e79711..c49b5bf6 100644 --- a/memind-integrations/claude-code/README.md +++ b/memind-integrations/claude-code/README.md @@ -13,7 +13,8 @@ The integration is intentionally small: - Uses the official Memind Python client. - No local daemon management. - No MCP dependency. -- Captures coding-agent tool and command activity as Memind `agent_timeline` raw data. +- Captures each coding-agent turn as Memind `agent_timeline` raw data: user prompt, tool/command activity, + optional final assistant text, and stop boundary. ## What It Does @@ -100,7 +101,7 @@ The installed hooks are: | Claude Code event | Script | Timeout | Purpose | | --- | --- | ---: | --- | | `SessionStart` | `scripts/session_start.py` | 5s | Health check, replay at most one failed retry payload, and clean old state. | -| `UserPromptSubmit` | `scripts/retrieve.py` | 12s | Retrieve relevant Memind context for the current user prompt. | +| `UserPromptSubmit` | `scripts/retrieve.py` | 12s | Buffer the user prompt event and retrieve relevant Memind context. | | `PreToolUse` | `scripts/pre_tool_use.py` | 5s | Buffer a redacted tool-start event in local session state. | | `PostToolUse` | `scripts/post_tool_use.py` | 5s | Buffer a redacted tool-result event in local session state. | | `PreCompact` | `scripts/pre_compact.py` | 30s | Flush buffered `agent_timeline` events before context compaction. | @@ -146,7 +147,7 @@ Settings are loaded in this order: | `agentIdMode` | `project` | `project` appends a stable project suffix; any other value uses `agentId` as-is. | | `sourceClient` | `claude-code` | Source marker stored with Memind data. | | `autoRetrieve` | `true` | Enables prompt-time memory retrieval. | -| `autoIngestAgentTimeline` | `true` | Enables `PreToolUse`/`PostToolUse` event buffering and `agent_timeline` rawdata flush. | +| `autoIngestAgentTimeline` | `true` | Enables user prompt, tool/result, assistant message, and stop event buffering plus `agent_timeline` rawdata flush. | | `retrieveStrategy` | `SIMPLE` | Memind retrieval strategy. | | `retrieveMaxEntries` | `8` | Maximum formatted memory entries injected into Claude Code. | | `retrieveMaxChars` | `6000` | Maximum injected context characters. | @@ -229,7 +230,8 @@ Agent memory items are grouped separately when returned by Memind: ## Ingestion Behavior Ingestion is timeline-only for Claude Code. The plugin does not submit transcript conversation rawdata. It buffers -tool and command events under `~/.memind/claude-code/state/` and flushes them through +one turn timeline under `~/.memind/claude-code/state/`: the submitted user prompt, tool and command events, the +latest assistant message when available from the transcript, and a stop boundary. It flushes the turn through `AsyncMemindClient.memory.extract(...)` as agent timeline rawdata. A typical timeline payload looks like: ```json @@ -241,19 +243,43 @@ tool and command events under `~/.memind/claude-code/state/` and flushes them th "type": "agent_timeline", "sourceClient": "claude-code", "sessionId": "session-123", - "agentTurnId": "session-123-agent-turn-1-1", - "timelineId": "session-123-agent-1-2", + "agentTurnId": "session-123-turn-1", + "timelineId": "session-123-turn-1-timeline", "project": {"name": "payment-service", "rootPath": "/repo/payment-service"}, "events": [ { "eventId": "event-id", "seq": 1, + "kind": "user_prompt", + "text": "Fix payment tests", + "status": "success", + "metadata": {"turnId": "session-123-turn-1", "turnSeq": 1} + }, + { + "eventId": "event-id", + "seq": 2, "kind": "command", "toolName": "Bash", "command": "npm test payment", "status": "failed", "exitCode": 1, - "output": "{\"stdout\": \"rounding mismatch\"}" + "output": "{\"stdout\": \"rounding mismatch\"}", + "metadata": {"turnId": "session-123-turn-1", "turnSeq": 1} + }, + { + "eventId": "event-id", + "seq": 3, + "kind": "assistant_message", + "text": "Updated calc.ts and payment tests now pass.", + "status": "success", + "metadata": {"turnId": "session-123-turn-1", "turnSeq": 1} + }, + { + "eventId": "event-id", + "seq": 4, + "kind": "stop", + "status": "success", + "metadata": {"turnId": "session-123-turn-1", "turnSeq": 1} } ] } diff --git a/memind-integrations/claude-code/scripts/ingest.py b/memind-integrations/claude-code/scripts/ingest.py index e416a399..21d87946 100644 --- a/memind-integrations/claude-code/scripts/ingest.py +++ b/memind-integrations/claude-code/scripts/ingest.py @@ -22,8 +22,13 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from lib.client import MemindClient -from lib.agent_timeline import build_timeline_payload +from lib.agent_timeline import ( + build_timeline_payload, + normalize_assistant_message_event, + normalize_stop_event, +) from lib.config import load_config +from lib.content import read_last_assistant_message from lib.identity import resolve_identity from lib.logging_utils import debug_log from lib.retry import RetrySpool @@ -60,6 +65,29 @@ def _spool_agent_timeline(retry_spool, identity, source_client, session_id, even ) +def _is_stop_hook(hook_input): + return (hook_input.get("hook_event_name") or "") == "Stop" + + +def _append_stop_events(state, session_id, hook_input): + if not _is_stop_hook(hook_input): + return None + turn_id, turn_seq = state.ensure_agent_turn(session_id) + assistant_text = read_last_assistant_message(hook_input.get("transcript_path")) + if assistant_text: + seq = state.next_agent_seq() + state.append_agent_event( + normalize_assistant_message_event( + hook_input, seq, turn_id=turn_id, turn_seq=turn_seq, text=assistant_text + ) + ) + seq = state.next_agent_seq() + state.append_agent_event( + normalize_stop_event(hook_input, seq, turn_id=turn_id, turn_seq=turn_seq) + ) + return turn_id + + async def ingest_messages_async(config, hook_input): identity = resolve_identity(config, hook_input) client = MemindClient(config["memindApiUrl"], config.get("memindApiToken"), timeout=10, max_retries=0) @@ -68,8 +96,14 @@ async def ingest_messages_async(config, hook_input): session_id = hook_input.get("session_id") or "unknown-session" source_client = config.get("sourceClient") agent_events_submitted = 0 + submitted_turn_id = None with store.locked(session_id) as state: - agent_events = state.agent_events() if config.get("autoIngestAgentTimeline", True) else [] + if config.get("autoIngestAgentTimeline", True): + hook_input["source_client"] = source_client or "claude-code" + submitted_turn_id = _append_stop_events(state, session_id, hook_input) + agent_events = state.agent_events() + else: + agent_events = [] if agent_events: timeline_payload = build_timeline_payload( config, @@ -101,6 +135,7 @@ async def ingest_messages_async(config, hook_input): state.clear_agent_events( [event["eventId"] for event in agent_events if event.get("eventId")] ) + state.close_agent_turn(submitted_turn_id) else: _spool_agent_timeline( retry_spool, diff --git a/memind-integrations/claude-code/scripts/lib/agent_timeline.py b/memind-integrations/claude-code/scripts/lib/agent_timeline.py index f16d719c..9fb99f74 100644 --- a/memind-integrations/claude-code/scripts/lib/agent_timeline.py +++ b/memind-integrations/claude-code/scripts/lib/agent_timeline.py @@ -71,7 +71,7 @@ def _json_text(value): return json.dumps(value, ensure_ascii=False, sort_keys=True) -def event_id(source_client, session_id, seq, hook_input): +def event_id(source_client, session_id, seq, hook_input, kind=None, text=None): hook_name = hook_input.get("hook_event_name") or "" tool_name = hook_input.get("tool_name") or "" timestamp = hook_input.get("timestamp") or "" @@ -83,13 +83,73 @@ def event_id(source_client, session_id, seq, hook_input): "hook": hook_name, "tool": tool_name, "timestamp": timestamp, + "kind": kind or "", + "textHash": hashlib.sha256((text or "").encode("utf-8")).hexdigest(), }, sort_keys=True, ) return hashlib.sha256(stable.encode("utf-8")).hexdigest() -def normalize_hook_event(hook_input, seq): +def _base_event(hook_input, seq, kind, turn_id=None, turn_seq=None, text=None): + source_client = hook_input.get("source_client") or "claude-code" + session_id = hook_input.get("session_id") or "unknown-session" + metadata = {"hookEventName": hook_input.get("hook_event_name")} + if turn_id: + metadata["turnId"] = turn_id + if turn_seq is not None: + metadata["turnSeq"] = turn_seq + return { + "eventId": event_id(source_client, session_id, seq, hook_input, kind, text), + "seq": seq, + "kind": kind, + "occurredAt": hook_input.get("timestamp"), + "metadata": {key: value for key, value in metadata.items() if value is not None}, + } + + +def normalize_user_prompt_event(hook_input, seq, turn_id=None, turn_seq=None): + text, redaction_kinds = redact_text(hook_input.get("prompt") or hook_input.get("user_prompt") or "") + event = _base_event(hook_input, seq, "user_prompt", turn_id, turn_seq, text) + event["text"] = text + event["status"] = "success" + if redaction_kinds: + metadata = dict(event["metadata"]) + metadata["redacted"] = True + metadata["redactionKinds"] = sorted(set(redaction_kinds)) + event["metadata"] = metadata + return {key: value for key, value in event.items() if value is not None and value != ""} + + +def normalize_assistant_message_event(hook_input, seq, turn_id=None, turn_seq=None, text=None): + redacted, redaction_kinds = redact_text(text or "") + event = _base_event(hook_input, seq, "assistant_message", turn_id, turn_seq, redacted) + event["text"] = redacted + event["status"] = "success" + if redaction_kinds: + metadata = dict(event["metadata"]) + metadata["redacted"] = True + metadata["redactionKinds"] = sorted(set(redaction_kinds)) + event["metadata"] = metadata + return {key: value for key, value in event.items() if value is not None and value != ""} + + +def normalize_stop_event(hook_input, seq, turn_id=None, turn_seq=None): + text = hook_input.get("reason") or hook_input.get("stop_reason") or "" + event = _base_event(hook_input, seq, "stop", turn_id, turn_seq, text) + if text: + redacted, redaction_kinds = redact_text(text) + event["text"] = redacted + if redaction_kinds: + metadata = dict(event["metadata"]) + metadata["redacted"] = True + metadata["redactionKinds"] = sorted(set(redaction_kinds)) + event["metadata"] = metadata + event["status"] = "success" + return {key: value for key, value in event.items() if value is not None and value != ""} + + +def normalize_hook_event(hook_input, seq, turn_id=None, turn_seq=None): source_client = hook_input.get("source_client") or "claude-code" session_id = hook_input.get("session_id") or "unknown-session" tool_name = hook_input.get("tool_name") @@ -99,7 +159,7 @@ def normalize_hook_event(hook_input, seq): redaction_kinds = [] event = { - "eventId": event_id(source_client, session_id, seq, hook_input), + "eventId": event_id(source_client, session_id, seq, hook_input, _event_kind(tool_name)), "seq": seq, "kind": _event_kind(tool_name), "occurredAt": hook_input.get("timestamp"), @@ -130,9 +190,11 @@ def normalize_hook_event(hook_input, seq): event["output"] = _json_text(redacted_output) redaction_kinds.extend(kinds) - metadata = { - "hookEventName": hook_input.get("hook_event_name"), - } + metadata = {"hookEventName": hook_input.get("hook_event_name")} + if turn_id: + metadata["turnId"] = turn_id + if turn_seq is not None: + metadata["turnSeq"] = turn_seq if redaction_kinds: metadata["redacted"] = True metadata["redactionKinds"] = sorted(set(redaction_kinds)) @@ -155,13 +217,15 @@ def build_timeline_payload(config, identity, session_id, events, hook_input): cwd = hook_input.get("cwd") first_seq = events[0].get("seq") if events else 0 last_seq = events[-1].get("seq") if events else 0 - agent_turn_id = f"{session_id}-agent-turn-{first_seq}-{last_seq}" + turn_id = _shared_metadata(events, "turnId") + turn_seq = _shared_metadata(events, "turnSeq") + agent_turn_id = turn_id or f"{session_id}-agent-turn-{first_seq}-{last_seq}" payload = { "type": "agent_timeline", "sourceClient": source_client, "sessionId": session_id, "agentTurnId": agent_turn_id, - "timelineId": f"{session_id}-agent-{first_seq}-{last_seq}", + "timelineId": f"{agent_turn_id}-timeline", "events": list(events), "metadata": { "userId": identity.get("userId"), @@ -169,7 +233,23 @@ def build_timeline_payload(config, identity, session_id, events, hook_input): "eventIds": [event["eventId"] for event in events if event.get("eventId")], }, } + if turn_id: + payload["metadata"]["turnId"] = turn_id + if turn_seq is not None: + payload["metadata"]["turnSeq"] = turn_seq if cwd: path = Path(cwd) payload["project"] = {"name": path.name, "rootPath": str(path)} return payload + + +def _shared_metadata(events, key): + values = [] + for event in events: + metadata = event.get("metadata") or {} + value = metadata.get(key) + if value is not None: + values.append(value) + if len(set(values)) == 1: + return values[0] + return None diff --git a/memind-integrations/claude-code/scripts/lib/content.py b/memind-integrations/claude-code/scripts/lib/content.py index e7b71720..41d866e2 100644 --- a/memind-integrations/claude-code/scripts/lib/content.py +++ b/memind-integrations/claude-code/scripts/lib/content.py @@ -86,3 +86,19 @@ def read_recent_context(path, turns): break entries.reverse() return "\n".join(f"{role}: {text}" for role, text in entries) + + +def read_last_assistant_message(path): + if not path or not Path(path).exists(): + return "" + for line in reversed(_tail_lines(path)): + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if str(entry.get("type", "")).lower() != "assistant": + continue + texts = _text_blocks((entry.get("message") or {}).get("content")) + if texts: + return texts[0] + return "" diff --git a/memind-integrations/claude-code/scripts/lib/state.py b/memind-integrations/claude-code/scripts/lib/state.py index 7be16acf..4f4e4986 100644 --- a/memind-integrations/claude-code/scripts/lib/state.py +++ b/memind-integrations/claude-code/scripts/lib/state.py @@ -32,6 +32,7 @@ def __init__(self, data): self.data = data self.data.setdefault("agentEvents", []) self.data.setdefault("nextAgentSeq", 1) + self.data.setdefault("nextAgentTurnSeq", 1) def append_agent_event(self, event): events = list(self.data.get("agentEvents", [])) @@ -65,6 +66,35 @@ def next_agent_seq(self): self.data["updatedAt"] = time.time() return seq + def current_agent_turn(self): + turn_id = self.data.get("currentAgentTurnId") + turn_seq = self.data.get("currentAgentTurnSeq") + if not turn_id or turn_seq is None: + return None, None + return turn_id, int(turn_seq) + + def start_agent_turn(self, session_id): + turn_seq = int(self.data.get("nextAgentTurnSeq", 1)) + self.data["nextAgentTurnSeq"] = turn_seq + 1 + turn_id = f"{_safe_session_id(session_id)}-turn-{turn_seq}" + self.data["currentAgentTurnId"] = turn_id + self.data["currentAgentTurnSeq"] = turn_seq + self.data["updatedAt"] = time.time() + return turn_id, turn_seq + + def ensure_agent_turn(self, session_id): + turn_id, turn_seq = self.current_agent_turn() + if turn_id: + return turn_id, turn_seq + return self.start_agent_turn(session_id) + + def close_agent_turn(self, turn_id=None): + current_turn_id = self.data.get("currentAgentTurnId") + if turn_id is None or turn_id == current_turn_id: + self.data.pop("currentAgentTurnId", None) + self.data.pop("currentAgentTurnSeq", None) + self.data["updatedAt"] = time.time() + class SessionStateStore: def __init__(self, root): diff --git a/memind-integrations/claude-code/scripts/post_tool_use.py b/memind-integrations/claude-code/scripts/post_tool_use.py index f2a2ab25..c916adea 100644 --- a/memind-integrations/claude-code/scripts/post_tool_use.py +++ b/memind-integrations/claude-code/scripts/post_tool_use.py @@ -33,8 +33,9 @@ def main(): session_id = hook_input.get("session_id") or "unknown-session" hook_input["source_client"] = config.get("sourceClient") or "claude-code" with SessionStateStore(state_root()).locked(session_id) as state: + turn_id, turn_seq = state.ensure_agent_turn(session_id) seq = state.next_agent_seq() - event = normalize_hook_event(hook_input, seq) + event = normalize_hook_event(hook_input, seq, turn_id=turn_id, turn_seq=turn_seq) state.append_agent_event(event) except Exception as exc: try: diff --git a/memind-integrations/claude-code/scripts/pre_tool_use.py b/memind-integrations/claude-code/scripts/pre_tool_use.py index 9bc7e051..d4c2d77d 100644 --- a/memind-integrations/claude-code/scripts/pre_tool_use.py +++ b/memind-integrations/claude-code/scripts/pre_tool_use.py @@ -33,8 +33,9 @@ def main(): session_id = hook_input.get("session_id") or "unknown-session" hook_input["source_client"] = config.get("sourceClient") or "claude-code" with SessionStateStore(state_root()).locked(session_id) as state: + turn_id, turn_seq = state.ensure_agent_turn(session_id) seq = state.next_agent_seq() - event = normalize_hook_event(hook_input, seq) + event = normalize_hook_event(hook_input, seq, turn_id=turn_id, turn_seq=turn_seq) state.append_agent_event(event) except Exception as exc: try: diff --git a/memind-integrations/claude-code/scripts/retrieve.py b/memind-integrations/claude-code/scripts/retrieve.py index 7742e916..af7c6906 100644 --- a/memind-integrations/claude-code/scripts/retrieve.py +++ b/memind-integrations/claude-code/scripts/retrieve.py @@ -20,10 +20,13 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from lib.client import MemindClient +from lib.agent_timeline import normalize_user_prompt_event from lib.config import load_config from lib.content import read_recent_context from lib.identity import resolve_identity from lib.logging_utils import debug_log +from lib.state import SessionStateStore +from ingest import state_root AGENT_CATEGORY_SECTIONS = [ @@ -97,11 +100,21 @@ def main(): try: hook_input = json.loads(sys.stdin.read() or "{}") config = load_config() + session_id = hook_input.get("session_id") or "unknown-session" + prompt = hook_input.get("prompt") or "" + hook_input["source_client"] = config.get("sourceClient") or "claude-code" + with SessionStateStore(state_root()).locked(session_id) as state: + turn_id, turn_seq = state.start_agent_turn(session_id) + seq = state.next_agent_seq() + state.append_agent_event( + normalize_user_prompt_event( + hook_input, seq, turn_id=turn_id, turn_seq=turn_seq + ) + ) if not config.get("autoRetrieve", True): print(json.dumps({"continue": True})) return identity = resolve_identity(config, hook_input) - prompt = hook_input.get("prompt") or "" context_turns = int(config.get("retrieveContextTurns", 0)) recent_context = read_recent_context(hook_input.get("transcript_path"), context_turns) query = prompt if not recent_context else f"{recent_context}\ncurrent: {prompt}" diff --git a/memind-integrations/claude-code/tests/test_agent_timeline.py b/memind-integrations/claude-code/tests/test_agent_timeline.py index 874c53e5..03ba0a1e 100644 --- a/memind-integrations/claude-code/tests/test_agent_timeline.py +++ b/memind-integrations/claude-code/tests/test_agent_timeline.py @@ -15,7 +15,13 @@ import json import unittest -from scripts.lib.agent_timeline import build_timeline_payload, normalize_hook_event +from scripts.lib.agent_timeline import ( + build_timeline_payload, + normalize_assistant_message_event, + normalize_hook_event, + normalize_stop_event, + normalize_user_prompt_event, +) class AgentTimelineTest(unittest.TestCase): @@ -30,6 +36,8 @@ def test_normalizes_post_tool_use_to_command_event(self): "timestamp": "2026-05-24T10:00:00Z", }, seq=1, + turn_id="s-turn-1", + turn_seq=1, ) self.assertEqual(event["kind"], "command") @@ -40,6 +48,57 @@ def test_normalizes_post_tool_use_to_command_event(self): self.assertEqual(event["status"], "failed") self.assertEqual(event["exitCode"], 1) self.assertEqual(event["output"], '{"stdout": "rounding mismatch"}') + self.assertEqual(event["metadata"]["turnId"], "s-turn-1") + self.assertEqual(event["metadata"]["turnSeq"], 1) + + def test_normalizes_user_prompt_and_stop_events_with_turn_metadata(self): + prompt_event = normalize_user_prompt_event( + { + "hook_event_name": "UserPromptSubmit", + "session_id": "s", + "prompt": "Fix payment tests", + "timestamp": "2026-05-24T10:00:00Z", + }, + seq=1, + turn_id="s-turn-1", + turn_seq=1, + ) + stop_event = normalize_stop_event( + { + "hook_event_name": "Stop", + "session_id": "s", + "timestamp": "2026-05-24T10:04:00Z", + }, + seq=2, + turn_id="s-turn-1", + turn_seq=1, + ) + + self.assertEqual(prompt_event["kind"], "user_prompt") + self.assertEqual(prompt_event["text"], "Fix payment tests") + self.assertEqual(prompt_event["metadata"]["turnId"], "s-turn-1") + self.assertEqual(prompt_event["metadata"]["turnSeq"], 1) + self.assertEqual(stop_event["kind"], "stop") + self.assertEqual(stop_event["status"], "success") + self.assertEqual(stop_event["metadata"]["turnId"], "s-turn-1") + + def test_normalizes_assistant_message_event_from_transcript_text(self): + event = normalize_assistant_message_event( + { + "hook_event_name": "Stop", + "session_id": "s", + "timestamp": "2026-05-24T10:04:00Z", + }, + seq=3, + turn_id="s-turn-1", + turn_seq=1, + text="Updated calc.ts and tests now pass.", + ) + + self.assertEqual(event["kind"], "assistant_message") + self.assertEqual(event["text"], "Updated calc.ts and tests now pass.") + self.assertEqual(event["status"], "success") + self.assertEqual(event["metadata"]["turnId"], "s-turn-1") def test_redacts_secret_fields_before_spool(self): event = normalize_hook_event( @@ -74,6 +133,8 @@ def test_builds_agent_timeline_payload(self): "timestamp": "2026-05-24T10:00:00Z", }, seq=1, + turn_id="s-turn-2", + turn_seq=2, ) payload = build_timeline_payload( @@ -87,8 +148,10 @@ def test_builds_agent_timeline_payload(self): self.assertEqual(payload["type"], "agent_timeline") self.assertEqual(payload["sourceClient"], "claude-code") self.assertEqual(payload["sessionId"], "s") - self.assertEqual(payload["agentTurnId"], "s-agent-turn-1-1") - self.assertEqual(payload["timelineId"], "s-agent-1-1") + self.assertEqual(payload["agentTurnId"], "s-turn-2") + self.assertEqual(payload["timelineId"], "s-turn-2-timeline") + self.assertEqual(payload["metadata"]["turnId"], "s-turn-2") + self.assertEqual(payload["metadata"]["turnSeq"], 2) self.assertIn("eventId", payload["events"][0]) self.assertEqual(payload["events"][0]["seq"], 1) self.assertEqual(payload["project"]["name"], "project") diff --git a/memind-integrations/claude-code/tests/test_hooks.py b/memind-integrations/claude-code/tests/test_hooks.py index 233b8511..9eeaba65 100644 --- a/memind-integrations/claude-code/tests/test_hooks.py +++ b/memind-integrations/claude-code/tests/test_hooks.py @@ -186,6 +186,35 @@ def test_post_tool_use_fails_open(self): self.assertEqual(event["kind"], "command") self.assertEqual(event["status"], "success") + def test_retrieve_buffers_user_prompt_event_before_memory_lookup(self): + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + env = { + "CLAUDE_PLUGIN_ROOT": str(ROOT), + "PYTHONPATH": str(ROOT), + "MEMIND_CLAUDE_STATE_ROOT": str(state_dir), + "MEMIND_API_URL": "http://127.0.0.1:9", + } + output = self.run_hook( + "retrieve.py", + { + "hook_event_name": "UserPromptSubmit", + "cwd": tmp, + "session_id": "s1", + "prompt": "Fix payment tests", + "timestamp": "2026-05-24T10:00:00Z", + }, + env=env, + ) + self.assertEqual(output, {"continue": True}) + state_file = next(state_dir.glob("*.json")) + state = json.loads(state_file.read_text()) + event = state["agentEvents"][0] + self.assertEqual(event["kind"], "user_prompt") + self.assertEqual(event["text"], "Fix payment tests") + self.assertEqual(event["metadata"]["turnSeq"], 1) + self.assertTrue(event["metadata"]["turnId"].startswith("s1-turn-")) + def test_retrieve_fail_open_when_memind_unavailable(self): with tempfile.TemporaryDirectory() as tmp: env = { @@ -274,9 +303,36 @@ def test_ingest_flushes_agent_timeline_and_clears_events_on_success(self): } with tempfile.TemporaryDirectory() as tmp: state_dir = Path(tmp) / "state" + transcript = Path(tmp) / "transcript.jsonl" + transcript.write_text( + json.dumps( + { + "type": "assistant", + "message": { + "content": "Updated calc.ts and payment tests now pass." + }, + } + ) + + "\n" + ) with SessionStateStore(state_dir).locked("s1") as state: state.append_agent_event( - {"eventId": "e1", "seq": 1, "kind": "command", "command": "npm test"} + { + "eventId": "e1", + "seq": 1, + "kind": "user_prompt", + "text": "Fix payment tests", + "metadata": {"turnId": "s1-turn-1", "turnSeq": 1}, + } + ) + state.append_agent_event( + { + "eventId": "e2", + "seq": 2, + "kind": "command", + "command": "npm test", + "metadata": {"turnId": "s1-turn-1", "turnSeq": 1}, + } ) with mock.patch.object(ingest, "state_root", return_value=state_dir): with mock.patch.object(ingest, "retry_root", return_value=Path(tmp) / "retry"): @@ -286,16 +342,25 @@ def test_ingest_flushes_agent_timeline_and_clears_events_on_success(self): result = ingest.ingest_messages( config, { + "hook_event_name": "Stop", "session_id": "s1", "cwd": tmp, + "transcript_path": str(transcript), + "timestamp": "2026-05-24T10:04:00Z", }, ) - self.assertEqual(result["agentEventsSubmitted"], 1) + self.assertEqual(result["agentEventsSubmitted"], 4) client.extract.assert_awaited_once() raw_content = client.extract.await_args.args[2] self.assertEqual(raw_content["type"], "agent_timeline") self.assertEqual(raw_content["sessionId"], "s1") self.assertEqual(raw_content["events"][0]["eventId"], "e1") + self.assertEqual( + [event["kind"] for event in raw_content["events"]], + ["user_prompt", "command", "assistant_message", "stop"], + ) + self.assertEqual(raw_content["agentTurnId"], "s1-turn-1") + self.assertEqual(raw_content["metadata"]["turnId"], "s1-turn-1") with SessionStateStore(state_dir).locked("s1") as state: self.assertEqual(state.agent_events(), []) diff --git a/memind-integrations/codex/README.md b/memind-integrations/codex/README.md index 9311a0df..c21cdc53 100644 --- a/memind-integrations/codex/README.md +++ b/memind-integrations/codex/README.md @@ -12,7 +12,8 @@ The integration is intentionally small: - Uses the official Memind Python client. - No Codex marketplace dependency. - No overwrite of existing Codex hooks or config. -- Captures coding-agent tool and command activity as Memind `agent_timeline` raw data. +- Captures each coding-agent turn as Memind `agent_timeline` raw data: user prompt, tool/command activity, + optional final assistant text, and stop boundary. ## What It Does @@ -120,7 +121,7 @@ The installed hooks are: | Codex event | Script | Timeout | Purpose | | --- | --- | ---: | --- | | `SessionStart` | `scripts/session_start.py` | 5s | Replay at most one failed timeline payload and clean old state. | -| `UserPromptSubmit` | `scripts/retrieve.py` | 12s | Retrieve relevant Memind context for the current user prompt. | +| `UserPromptSubmit` | `scripts/retrieve.py` | 12s | Buffer the user prompt event and retrieve relevant Memind context. | | `PreToolUse` | `scripts/pre_tool_use.py` | 5s | Buffer a redacted tool-start event in local session state. | | `PostToolUse` | `scripts/post_tool_use.py` | 5s | Buffer a redacted tool-result event in local session state. | | `Stop` | `scripts/ingest.py` | 15s | Flush buffered `agent_timeline` events after a turn. | @@ -161,7 +162,7 @@ Settings are loaded in this order: | `agentIdMode` | `project` | `project` appends a stable project suffix; any other value uses `agentId` as-is. | | `sourceClient` | `codex` | Source marker stored with Memind data. | | `autoRetrieve` | `true` | Enables prompt-time memory retrieval. | -| `autoIngestAgentTimeline` | `true` | Enables `PreToolUse`/`PostToolUse` event buffering and `agent_timeline` rawdata flush. | +| `autoIngestAgentTimeline` | `true` | Enables user prompt, tool/result, assistant message, and stop event buffering plus `agent_timeline` rawdata flush. | | `retrieveStrategy` | `SIMPLE` | Memind retrieval strategy. | | `retrieveMaxEntries` | `8` | Maximum formatted memory entries injected into Codex. | | `retrieveMaxChars` | `6000` | Maximum injected context characters. | @@ -243,8 +244,9 @@ Agent memory items are grouped separately when returned by Memind: ## Ingestion Behavior -Ingestion is timeline-only for Codex. The plugin does not submit transcript conversation rawdata. It buffers tool -and command events under `~/.memind/codex/state/` and flushes them through +Ingestion is timeline-only for Codex. The plugin does not submit transcript conversation rawdata. It buffers one +turn timeline under `~/.memind/codex/state/`: the submitted user prompt, tool and command events, the latest +assistant message when available from the transcript, and a stop boundary. It flushes the turn through `AsyncMemindClient.memory.extract(...)` as agent timeline rawdata. A typical timeline payload looks like: ```json @@ -256,19 +258,43 @@ and command events under `~/.memind/codex/state/` and flushes them through "type": "agent_timeline", "sourceClient": "codex", "sessionId": "session-123", - "agentTurnId": "session-123-agent-turn-1-1", - "timelineId": "session-123-agent-1-2", + "agentTurnId": "session-123-turn-1", + "timelineId": "session-123-turn-1-timeline", "project": {"name": "payment-service", "rootPath": "/repo/payment-service"}, "events": [ { "eventId": "event-id", "seq": 1, + "kind": "user_prompt", + "text": "Fix payment tests", + "status": "success", + "metadata": {"turnId": "session-123-turn-1", "turnSeq": 1} + }, + { + "eventId": "event-id", + "seq": 2, "kind": "command", "toolName": "Bash", "command": "npm test payment", "status": "failed", "exitCode": 1, - "output": "{\"stdout\": \"rounding mismatch\"}" + "output": "{\"stdout\": \"rounding mismatch\"}", + "metadata": {"turnId": "session-123-turn-1", "turnSeq": 1} + }, + { + "eventId": "event-id", + "seq": 3, + "kind": "assistant_message", + "text": "Updated calc.ts and payment tests now pass.", + "status": "success", + "metadata": {"turnId": "session-123-turn-1", "turnSeq": 1} + }, + { + "eventId": "event-id", + "seq": 4, + "kind": "stop", + "status": "success", + "metadata": {"turnId": "session-123-turn-1", "turnSeq": 1} } ] } diff --git a/memind-integrations/codex/scripts/ingest.py b/memind-integrations/codex/scripts/ingest.py index 4a880242..a9f25ee8 100644 --- a/memind-integrations/codex/scripts/ingest.py +++ b/memind-integrations/codex/scripts/ingest.py @@ -23,8 +23,13 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from lib.client import MemindClient -from lib.agent_timeline import build_timeline_payload +from lib.agent_timeline import ( + build_timeline_payload, + normalize_assistant_message_event, + normalize_stop_event, +) from lib.config import load_config +from lib.content import read_last_assistant_message from lib.identity import resolve_identity from lib.logging_utils import debug_log from lib.retry import RetrySpool @@ -61,6 +66,29 @@ def _spool_agent_timeline(retry_spool, identity, source_client, session_key, eve ) +def _is_stop_hook(hook_input): + return (hook_input.get("hook_event_name") or "") == "Stop" + + +def _append_stop_events(state, session_key, hook_input): + if not _is_stop_hook(hook_input): + return None + turn_id, turn_seq = state.ensure_agent_turn(session_key) + assistant_text = read_last_assistant_message(hook_input.get("transcript_path")) + if assistant_text: + seq = state.next_agent_seq() + state.append_agent_event( + normalize_assistant_message_event( + hook_input, seq, turn_id=turn_id, turn_seq=turn_seq, text=assistant_text + ) + ) + seq = state.next_agent_seq() + state.append_agent_event( + normalize_stop_event(hook_input, seq, turn_id=turn_id, turn_seq=turn_seq) + ) + return turn_id + + async def ingest_messages_async(config, hook_input): identity = resolve_identity(config, hook_input) client = MemindClient(config["memindApiUrl"], config.get("memindApiToken"), timeout=10, max_retries=0) @@ -70,9 +98,15 @@ async def ingest_messages_async(config, hook_input): session_key = state_key(hook_input) source_client = config.get("sourceClient") agent_events_submitted = 0 + submitted_turn_id = None with store.locked(session_key) as state: - agent_events = state.agent_events() if config.get("autoIngestAgentTimeline", True) else [] + if config.get("autoIngestAgentTimeline", True): + hook_input["source_client"] = source_client or "codex" + submitted_turn_id = _append_stop_events(state, session_key, hook_input) + agent_events = state.agent_events() + else: + agent_events = [] if agent_events: timeline_payload = build_timeline_payload( @@ -102,10 +136,11 @@ async def ingest_messages_async(config, hook_input): status = getattr(response, "status", None) if status == "SUCCESS": agent_events_submitted = len(agent_events) - store.clear_agent_events( - session_key, - [event["eventId"] for event in agent_events if event.get("eventId")], - ) + with store.locked(session_key) as state: + state.clear_agent_events( + [event["eventId"] for event in agent_events if event.get("eventId")] + ) + state.close_agent_turn(submitted_turn_id) else: _spool_agent_timeline( retry_spool, diff --git a/memind-integrations/codex/scripts/lib/agent_timeline.py b/memind-integrations/codex/scripts/lib/agent_timeline.py index d5fb1b92..80bc836b 100644 --- a/memind-integrations/codex/scripts/lib/agent_timeline.py +++ b/memind-integrations/codex/scripts/lib/agent_timeline.py @@ -71,7 +71,7 @@ def _json_text(value): return json.dumps(value, ensure_ascii=False, sort_keys=True) -def event_id(source_client, session_id, seq, hook_input): +def event_id(source_client, session_id, seq, hook_input, kind=None, text=None): hook_name = hook_input.get("hook_event_name") or "" tool_name = hook_input.get("tool_name") or "" timestamp = hook_input.get("timestamp") or "" @@ -83,13 +83,73 @@ def event_id(source_client, session_id, seq, hook_input): "hook": hook_name, "tool": tool_name, "timestamp": timestamp, + "kind": kind or "", + "textHash": hashlib.sha256((text or "").encode("utf-8")).hexdigest(), }, sort_keys=True, ) return hashlib.sha256(stable.encode("utf-8")).hexdigest() -def normalize_hook_event(hook_input, seq): +def _base_event(hook_input, seq, kind, turn_id=None, turn_seq=None, text=None): + source_client = hook_input.get("source_client") or "codex" + session_id = hook_input.get("session_id") or "unknown-session" + metadata = {"hookEventName": hook_input.get("hook_event_name")} + if turn_id: + metadata["turnId"] = turn_id + if turn_seq is not None: + metadata["turnSeq"] = turn_seq + return { + "eventId": event_id(source_client, session_id, seq, hook_input, kind, text), + "seq": seq, + "kind": kind, + "occurredAt": hook_input.get("timestamp"), + "metadata": {key: value for key, value in metadata.items() if value is not None}, + } + + +def normalize_user_prompt_event(hook_input, seq, turn_id=None, turn_seq=None): + text, redaction_kinds = redact_text(hook_input.get("prompt") or hook_input.get("user_prompt") or "") + event = _base_event(hook_input, seq, "user_prompt", turn_id, turn_seq, text) + event["text"] = text + event["status"] = "success" + if redaction_kinds: + metadata = dict(event["metadata"]) + metadata["redacted"] = True + metadata["redactionKinds"] = sorted(set(redaction_kinds)) + event["metadata"] = metadata + return {key: value for key, value in event.items() if value is not None and value != ""} + + +def normalize_assistant_message_event(hook_input, seq, turn_id=None, turn_seq=None, text=None): + redacted, redaction_kinds = redact_text(text or "") + event = _base_event(hook_input, seq, "assistant_message", turn_id, turn_seq, redacted) + event["text"] = redacted + event["status"] = "success" + if redaction_kinds: + metadata = dict(event["metadata"]) + metadata["redacted"] = True + metadata["redactionKinds"] = sorted(set(redaction_kinds)) + event["metadata"] = metadata + return {key: value for key, value in event.items() if value is not None and value != ""} + + +def normalize_stop_event(hook_input, seq, turn_id=None, turn_seq=None): + text = hook_input.get("reason") or hook_input.get("stop_reason") or "" + event = _base_event(hook_input, seq, "stop", turn_id, turn_seq, text) + if text: + redacted, redaction_kinds = redact_text(text) + event["text"] = redacted + if redaction_kinds: + metadata = dict(event["metadata"]) + metadata["redacted"] = True + metadata["redactionKinds"] = sorted(set(redaction_kinds)) + event["metadata"] = metadata + event["status"] = "success" + return {key: value for key, value in event.items() if value is not None and value != ""} + + +def normalize_hook_event(hook_input, seq, turn_id=None, turn_seq=None): source_client = hook_input.get("source_client") or "codex" session_id = hook_input.get("session_id") or "unknown-session" tool_name = hook_input.get("tool_name") @@ -99,7 +159,7 @@ def normalize_hook_event(hook_input, seq): redaction_kinds = [] event = { - "eventId": event_id(source_client, session_id, seq, hook_input), + "eventId": event_id(source_client, session_id, seq, hook_input, _event_kind(tool_name)), "seq": seq, "kind": _event_kind(tool_name), "occurredAt": hook_input.get("timestamp"), @@ -131,6 +191,10 @@ def normalize_hook_event(hook_input, seq): redaction_kinds.extend(kinds) metadata = {"hookEventName": hook_input.get("hook_event_name")} + if turn_id: + metadata["turnId"] = turn_id + if turn_seq is not None: + metadata["turnSeq"] = turn_seq if redaction_kinds: metadata["redacted"] = True metadata["redactionKinds"] = sorted(set(redaction_kinds)) @@ -153,13 +217,15 @@ def build_timeline_payload(config, identity, session_id, events, hook_input): cwd = hook_input.get("cwd") first_seq = events[0].get("seq") if events else 0 last_seq = events[-1].get("seq") if events else 0 - agent_turn_id = f"{session_id}-agent-turn-{first_seq}-{last_seq}" + turn_id = _shared_metadata(events, "turnId") + turn_seq = _shared_metadata(events, "turnSeq") + agent_turn_id = turn_id or f"{session_id}-agent-turn-{first_seq}-{last_seq}" payload = { "type": "agent_timeline", "sourceClient": source_client, "sessionId": session_id, "agentTurnId": agent_turn_id, - "timelineId": f"{session_id}-agent-{first_seq}-{last_seq}", + "timelineId": f"{agent_turn_id}-timeline", "events": list(events), "metadata": { "userId": identity.get("userId"), @@ -167,7 +233,23 @@ def build_timeline_payload(config, identity, session_id, events, hook_input): "eventIds": [event["eventId"] for event in events if event.get("eventId")], }, } + if turn_id: + payload["metadata"]["turnId"] = turn_id + if turn_seq is not None: + payload["metadata"]["turnSeq"] = turn_seq if cwd: path = Path(cwd) payload["project"] = {"name": path.name, "rootPath": str(path)} return payload + + +def _shared_metadata(events, key): + values = [] + for event in events: + metadata = event.get("metadata") or {} + value = metadata.get(key) + if value is not None: + values.append(value) + if len(set(values)) == 1: + return values[0] + return None diff --git a/memind-integrations/codex/scripts/lib/content.py b/memind-integrations/codex/scripts/lib/content.py index 67d6c8dd..e3cf7326 100644 --- a/memind-integrations/codex/scripts/lib/content.py +++ b/memind-integrations/codex/scripts/lib/content.py @@ -134,3 +134,17 @@ def read_recent_context(path, turns): break entries.reverse() return "\n".join(f"{role}: {text}" for role, text in entries) + + +def read_last_assistant_message(path): + if not path or not Path(path).exists(): + return "" + for line in reversed(_tail_lines(path)): + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + role, texts = _entry_texts(entry) + if role == "assistant" and texts: + return texts[0] + return "" diff --git a/memind-integrations/codex/scripts/lib/state.py b/memind-integrations/codex/scripts/lib/state.py index 2c71b0f8..4fd7dbb0 100644 --- a/memind-integrations/codex/scripts/lib/state.py +++ b/memind-integrations/codex/scripts/lib/state.py @@ -88,6 +88,7 @@ def __init__(self, data): self.data = data self.data.setdefault("agentEvents", []) self.data.setdefault("nextAgentSeq", 1) + self.data.setdefault("nextAgentTurnSeq", 1) def append_agent_event(self, event): events = list(self.data.get("agentEvents", [])) @@ -121,6 +122,35 @@ def next_agent_seq(self): self.data["updatedAt"] = time.time() return seq + def current_agent_turn(self): + turn_id = self.data.get("currentAgentTurnId") + turn_seq = self.data.get("currentAgentTurnSeq") + if not turn_id or turn_seq is None: + return None, None + return turn_id, int(turn_seq) + + def start_agent_turn(self, session_key): + turn_seq = int(self.data.get("nextAgentTurnSeq", 1)) + self.data["nextAgentTurnSeq"] = turn_seq + 1 + turn_id = f"{_safe_name(session_key)}-turn-{turn_seq}" + self.data["currentAgentTurnId"] = turn_id + self.data["currentAgentTurnSeq"] = turn_seq + self.data["updatedAt"] = time.time() + return turn_id, turn_seq + + def ensure_agent_turn(self, session_key): + turn_id, turn_seq = self.current_agent_turn() + if turn_id: + return turn_id, turn_seq + return self.start_agent_turn(session_key) + + def close_agent_turn(self, turn_id=None): + current_turn_id = self.data.get("currentAgentTurnId") + if turn_id is None or turn_id == current_turn_id: + self.data.pop("currentAgentTurnId", None) + self.data.pop("currentAgentTurnSeq", None) + self.data["updatedAt"] = time.time() + class SessionStateStore: def __init__(self, root): diff --git a/memind-integrations/codex/scripts/post_tool_use.py b/memind-integrations/codex/scripts/post_tool_use.py index 42961872..6050f9a9 100644 --- a/memind-integrations/codex/scripts/post_tool_use.py +++ b/memind-integrations/codex/scripts/post_tool_use.py @@ -33,8 +33,9 @@ def main(): hook_input["source_client"] = config.get("sourceClient") or "codex" session_key = state_key(hook_input) with SessionStateStore(state_root()).locked(session_key) as state: + turn_id, turn_seq = state.ensure_agent_turn(session_key) seq = state.next_agent_seq() - event = normalize_hook_event(hook_input, seq) + event = normalize_hook_event(hook_input, seq, turn_id=turn_id, turn_seq=turn_seq) state.append_agent_event(event) except Exception as exc: try: diff --git a/memind-integrations/codex/scripts/pre_tool_use.py b/memind-integrations/codex/scripts/pre_tool_use.py index 9a7ef987..93009465 100644 --- a/memind-integrations/codex/scripts/pre_tool_use.py +++ b/memind-integrations/codex/scripts/pre_tool_use.py @@ -33,8 +33,9 @@ def main(): hook_input["source_client"] = config.get("sourceClient") or "codex" session_key = state_key(hook_input) with SessionStateStore(state_root()).locked(session_key) as state: + turn_id, turn_seq = state.ensure_agent_turn(session_key) seq = state.next_agent_seq() - event = normalize_hook_event(hook_input, seq) + event = normalize_hook_event(hook_input, seq, turn_id=turn_id, turn_seq=turn_seq) state.append_agent_event(event) except Exception as exc: try: diff --git a/memind-integrations/codex/scripts/retrieve.py b/memind-integrations/codex/scripts/retrieve.py index 8c47c591..ae0857b8 100644 --- a/memind-integrations/codex/scripts/retrieve.py +++ b/memind-integrations/codex/scripts/retrieve.py @@ -21,10 +21,13 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from lib.client import MemindClient +from lib.agent_timeline import normalize_user_prompt_event from lib.config import load_config from lib.content import read_recent_context from lib.identity import resolve_identity from lib.logging_utils import debug_log +from lib.state import SessionStateStore, state_key +from ingest import state_root AGENT_CATEGORY_SECTIONS = [ @@ -98,11 +101,21 @@ def main(): try: hook_input = json.loads(sys.stdin.read() or "{}") config = load_config() + prompt = hook_input.get("prompt") or hook_input.get("user_prompt") or "" + hook_input["source_client"] = config.get("sourceClient") or "codex" + session_key = state_key(hook_input) + with SessionStateStore(state_root()).locked(session_key) as state: + turn_id, turn_seq = state.start_agent_turn(session_key) + seq = state.next_agent_seq() + state.append_agent_event( + normalize_user_prompt_event( + hook_input, seq, turn_id=turn_id, turn_seq=turn_seq + ) + ) if not config.get("autoRetrieve", True): print(json.dumps({"continue": True})) return identity = resolve_identity(config, hook_input) - prompt = hook_input.get("prompt") or hook_input.get("user_prompt") or "" context_turns = int(config.get("retrieveContextTurns", 0)) recent_context = read_recent_context(hook_input.get("transcript_path"), context_turns) query = prompt if not recent_context else f"{recent_context}\ncurrent: {prompt}" diff --git a/memind-integrations/codex/tests/test_agent_timeline.py b/memind-integrations/codex/tests/test_agent_timeline.py index 8a7c7336..6014178c 100644 --- a/memind-integrations/codex/tests/test_agent_timeline.py +++ b/memind-integrations/codex/tests/test_agent_timeline.py @@ -15,7 +15,13 @@ import json import unittest -from scripts.lib.agent_timeline import build_timeline_payload, normalize_hook_event +from scripts.lib.agent_timeline import ( + build_timeline_payload, + normalize_assistant_message_event, + normalize_hook_event, + normalize_stop_event, + normalize_user_prompt_event, +) class AgentTimelineTest(unittest.TestCase): @@ -31,6 +37,8 @@ def test_normalizes_post_tool_use_to_command_event(self): "source_client": "codex", }, seq=1, + turn_id="s-turn-1", + turn_seq=1, ) self.assertEqual(event["kind"], "command") @@ -41,6 +49,60 @@ def test_normalizes_post_tool_use_to_command_event(self): self.assertEqual(event["status"], "failed") self.assertEqual(event["exitCode"], 1) self.assertEqual(event["output"], '{"stderr": "rounding mismatch"}') + self.assertEqual(event["metadata"]["turnId"], "s-turn-1") + self.assertEqual(event["metadata"]["turnSeq"], 1) + + def test_normalizes_user_prompt_and_stop_events_with_turn_metadata(self): + prompt_event = normalize_user_prompt_event( + { + "hook_event_name": "UserPromptSubmit", + "session_id": "s", + "prompt": "Fix payment tests", + "timestamp": "2026-05-24T10:00:00Z", + "source_client": "codex", + }, + seq=1, + turn_id="s-turn-1", + turn_seq=1, + ) + stop_event = normalize_stop_event( + { + "hook_event_name": "Stop", + "session_id": "s", + "timestamp": "2026-05-24T10:04:00Z", + "source_client": "codex", + }, + seq=2, + turn_id="s-turn-1", + turn_seq=1, + ) + + self.assertEqual(prompt_event["kind"], "user_prompt") + self.assertEqual(prompt_event["text"], "Fix payment tests") + self.assertEqual(prompt_event["metadata"]["turnId"], "s-turn-1") + self.assertEqual(prompt_event["metadata"]["turnSeq"], 1) + self.assertEqual(stop_event["kind"], "stop") + self.assertEqual(stop_event["status"], "success") + self.assertEqual(stop_event["metadata"]["turnId"], "s-turn-1") + + def test_normalizes_assistant_message_event_from_transcript_text(self): + event = normalize_assistant_message_event( + { + "hook_event_name": "Stop", + "session_id": "s", + "timestamp": "2026-05-24T10:04:00Z", + "source_client": "codex", + }, + seq=3, + turn_id="s-turn-1", + turn_seq=1, + text="Updated calc.ts and tests now pass.", + ) + + self.assertEqual(event["kind"], "assistant_message") + self.assertEqual(event["text"], "Updated calc.ts and tests now pass.") + self.assertEqual(event["status"], "success") + self.assertEqual(event["metadata"]["turnId"], "s-turn-1") def test_redacts_secret_fields_before_spool(self): event = normalize_hook_event( @@ -71,6 +133,8 @@ def test_builds_agent_timeline_payload(self): "source_client": "codex", }, seq=1, + turn_id="s-turn-2", + turn_seq=2, ) payload = build_timeline_payload( @@ -84,8 +148,10 @@ def test_builds_agent_timeline_payload(self): self.assertEqual(payload["type"], "agent_timeline") self.assertEqual(payload["sourceClient"], "codex") self.assertEqual(payload["sessionId"], "s") - self.assertEqual(payload["agentTurnId"], "s-agent-turn-1-1") - self.assertEqual(payload["timelineId"], "s-agent-1-1") + self.assertEqual(payload["agentTurnId"], "s-turn-2") + self.assertEqual(payload["timelineId"], "s-turn-2-timeline") + self.assertEqual(payload["metadata"]["turnId"], "s-turn-2") + self.assertEqual(payload["metadata"]["turnSeq"], 2) self.assertIn("eventId", payload["events"][0]) self.assertEqual(payload["events"][0]["seq"], 1) self.assertEqual(payload["project"]["name"], "project") diff --git a/memind-integrations/codex/tests/test_hooks.py b/memind-integrations/codex/tests/test_hooks.py index cc167950..e3e1499b 100644 --- a/memind-integrations/codex/tests/test_hooks.py +++ b/memind-integrations/codex/tests/test_hooks.py @@ -184,6 +184,35 @@ def test_post_tool_use_fails_open_and_buffers_event(self): self.assertEqual(event["kind"], "command") self.assertEqual(event["status"], "success") + def test_retrieve_buffers_user_prompt_event_before_memory_lookup(self): + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + env = { + "CODEX_PLUGIN_ROOT": str(ROOT), + "PYTHONPATH": str(ROOT), + "MEMIND_CODEX_STATE_ROOT": str(state_dir), + "MEMIND_API_URL": "http://127.0.0.1:9", + } + output = self.run_hook( + "retrieve.py", + { + "hook_event_name": "UserPromptSubmit", + "cwd": tmp, + "session_id": "s1", + "prompt": "Fix payment tests", + "timestamp": "2026-05-24T10:00:00Z", + }, + env=env, + ) + self.assertEqual(output, {"continue": True}) + state_file = next(state_dir.glob("*.json")) + state = json.loads(state_file.read_text()) + event = state["agentEvents"][0] + self.assertEqual(event["kind"], "user_prompt") + self.assertEqual(event["text"], "Fix payment tests") + self.assertEqual(event["metadata"]["turnSeq"], 1) + self.assertTrue(event["metadata"]["turnId"].startswith("s1-turn-")) + def test_ingest_ignores_transcript_when_no_agent_events(self): sys.path.insert(0, str(ROOT / "scripts")) import ingest @@ -236,22 +265,67 @@ def test_ingest_flushes_agent_timeline_and_clears_events_on_success(self): } with tempfile.TemporaryDirectory() as tmp: state_root = Path(tmp) / "state" + transcript = Path(tmp) / "transcript.jsonl" + transcript.write_text( + json.dumps( + { + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Updated calc.ts and payment tests now pass.", + } + ], + } + ) + + "\n" + ) with SessionStateStore(state_root).locked("s1") as state: state.append_agent_event( - {"eventId": "e1", "seq": 1, "kind": "command", "command": "cargo test"} + { + "eventId": "e1", + "seq": 1, + "kind": "user_prompt", + "text": "Fix payment tests", + "metadata": {"turnId": "s1-turn-1", "turnSeq": 1}, + } + ) + state.append_agent_event( + { + "eventId": "e2", + "seq": 2, + "kind": "command", + "command": "cargo test", + "metadata": {"turnId": "s1-turn-1", "turnSeq": 1}, + } ) with mock.patch.object(ingest, "state_root", return_value=state_root): with mock.patch.object(ingest, "retry_root", return_value=Path(tmp) / "retry"): with mock.patch.object(ingest, "MemindClient") as client_cls: client = client_cls.return_value client.extract = mock.AsyncMock(return_value=types.SimpleNamespace(status="SUCCESS")) - result = ingest.ingest_messages(config, {"session_id": "s1", "cwd": tmp}) - self.assertEqual(result["agentEventsSubmitted"], 1) + result = ingest.ingest_messages( + config, + { + "hook_event_name": "Stop", + "session_id": "s1", + "cwd": tmp, + "transcript_path": str(transcript), + "timestamp": "2026-05-24T10:04:00Z", + }, + ) + self.assertEqual(result["agentEventsSubmitted"], 4) client.extract.assert_awaited_once() raw_content = client.extract.await_args.args[2] self.assertEqual(raw_content["type"], "agent_timeline") self.assertEqual(raw_content["sessionId"], "s1") self.assertEqual(raw_content["events"][0]["eventId"], "e1") + self.assertEqual( + [event["kind"] for event in raw_content["events"]], + ["user_prompt", "command", "assistant_message", "stop"], + ) + self.assertEqual(raw_content["agentTurnId"], "s1-turn-1") + self.assertEqual(raw_content["metadata"]["turnId"], "s1-turn-1") with SessionStateStore(state_root).locked("s1") as state: self.assertEqual(state.agent_events(), []) diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java index 94f1e141..fc978315 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java @@ -74,7 +74,7 @@ private List buildBaseEpisodes( List episodes = new ArrayList<>(); List current = new ArrayList<>(); AgentEvent previous = null; - String currentTaskKey = null; + String currentBoundaryKey = null; for (AgentEvent event : events) { boolean startsNewPrompt = @@ -84,23 +84,23 @@ private List buildBaseEpisodes( && (startsNewPrompt || exceedsGap(previous, event) || exceedsEventLimit(current) - || taskKeyChanged(currentTaskKey, taskKey(event))); + || boundaryKeyChanged(currentBoundaryKey, boundaryKey(event))); if (crossesBoundary) { episodes.add(buildEpisode(timeline, current, "full", Map.of())); current = new ArrayList<>(); - currentTaskKey = null; + currentBoundaryKey = null; } current.add(event); - if (currentTaskKey == null) { - currentTaskKey = taskKey(event); + if (currentBoundaryKey == null) { + currentBoundaryKey = boundaryKey(event); } previous = event; if (isTerminal(event)) { episodes.add(buildEpisode(timeline, current, "full", Map.of())); current = new ArrayList<>(); - currentTaskKey = null; + currentBoundaryKey = null; previous = null; } } @@ -356,13 +356,18 @@ private boolean exceedsEventLimit(List current) { return current.size() >= options.maxEventsPerEpisode(); } - private boolean taskKeyChanged(String currentTaskKey, String nextTaskKey) { - return hasText(currentTaskKey) - && hasText(nextTaskKey) - && !currentTaskKey.equals(nextTaskKey); + private boolean boundaryKeyChanged(String currentBoundaryKey, String nextBoundaryKey) { + return hasText(currentBoundaryKey) + && hasText(nextBoundaryKey) + && !currentBoundaryKey.equals(nextBoundaryKey); } - private String taskKey(AgentEvent event) { + private String boundaryKey(AgentEvent event) { + Object turnId = event.metadata().get("turnId"); + Object turnSeq = event.metadata().get("turnSeq"); + if (turnId != null || turnSeq != null) { + return normalized(turnId) + "|" + normalized(turnSeq); + } Object taskId = event.metadata().get("taskId"); Object subtaskId = event.metadata().get("subtaskId"); if (taskId == null && subtaskId == null) { diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java index 3b786f74..a5db4d02 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java @@ -199,6 +199,24 @@ void shouldSplitEpisodesWhenTaskMetadataChanges() { assertThat(episodes.get(1).eventIds()).containsExactly("e3"); } + @Test + void shouldSplitEpisodesWhenTurnMetadataChanges() { + List events = + List.of( + eventWithTurnMetadata( + "e1", 1, AgentEventKind.USER_PROMPT, "Fix payment tests", "turn-a"), + eventWithTurnMetadata("e2", 2, AgentEventKind.COMMAND, null, "turn-a"), + eventWithTurnMetadata("e3", 3, AgentEventKind.COMMAND, null, "turn-b")); + + List episodes = + new AgentEpisodeAssembler() + .assemble(AgentEpisodeTestSupport.paymentTimeline(events)); + + assertThat(episodes).hasSize(2); + assertThat(episodes.getFirst().eventIds()).containsExactly("e1", "e2"); + assertThat(episodes.get(1).eventIds()).containsExactly("e3"); + } + private static AgentEvent eventWithMetadata( String id, int seq, AgentEventKind kind, String text, String taskId) { AgentEvent base = @@ -232,4 +250,38 @@ private static AgentEvent eventWithMetadata( base.exitCode(), Map.of("taskId", taskId)); } + + private static AgentEvent eventWithTurnMetadata( + String id, int seq, AgentEventKind kind, String text, String turnId) { + AgentEvent base = + AgentEpisodeTestSupport.event( + id, + seq, + kind, + "2026-05-24T10:1" + seq + ":00Z", + text, + "Bash", + null, + AgentEventStatus.SUCCESS, + null, + null, + "npm test payment", + 0); + return new AgentEvent( + base.eventId(), + base.seq(), + base.kind(), + base.occurredAt(), + base.text(), + base.toolName(), + base.input(), + base.output(), + base.status(), + base.durationMs(), + base.path(), + base.operation(), + base.command(), + base.exitCode(), + Map.of("turnId", turnId)); + } } From fac660622465f02715f998535e821bd8737f0046 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Tue, 26 May 2026 01:15:22 +0800 Subject: [PATCH 24/54] Normalize agent timeline tool events --- .../claude-code/scripts/lib/agent_timeline.py | 286 ++++++++++++++++-- .../claude-code/tests/test_agent_timeline.py | 137 ++++++++- .../claude-code/tests/test_hooks.py | 4 +- .../codex/scripts/lib/agent_timeline.py | 286 ++++++++++++++++-- .../codex/tests/test_agent_timeline.py | 144 ++++++++- memind-integrations/codex/tests/test_hooks.py | 4 +- .../chunk/AgentEpisodeAssemblerTest.java | 102 +++++++ 7 files changed, 915 insertions(+), 48 deletions(-) diff --git a/memind-integrations/claude-code/scripts/lib/agent_timeline.py b/memind-integrations/claude-code/scripts/lib/agent_timeline.py index 9fb99f74..246e9255 100644 --- a/memind-integrations/claude-code/scripts/lib/agent_timeline.py +++ b/memind-integrations/claude-code/scripts/lib/agent_timeline.py @@ -19,6 +19,7 @@ MAX_TEXT_CHARS = 4000 +NORMALIZATION_VERSION = 1 SECRET_PATTERNS = [ ("openai_key", re.compile(r"sk-[A-Za-z0-9_-]{8,}")), @@ -26,6 +27,63 @@ ("private_key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.DOTALL)), ] +PATH_KEYS = [ + "file_path", + "filepath", + "filePath", + "path", + "file", + "target_file", + "targetFile", + "target_path", + "targetPath", + "notebook_path", + "notebookPath", +] +PATH_LIST_KEYS = ["files", "paths"] +COMMAND_KEYS = ["command", "cmd", "shell_command"] +SEARCH_PATTERN_KEYS = ["pattern", "query", "regex", "glob"] +URL_KEYS = ["url", "uri", "href"] + +TEST_COMMAND_PATTERNS = [ + re.compile(pattern, re.IGNORECASE) + for pattern in [ + r"(^|[\s;&|])(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|vitest|jest)(?:\b|:)", + r"(^|[\s;&|])pytest\b", + r"(^|[\s;&|])python(?:3)?\s+-m\s+unittest\b", + r"(^|[\s;&|])go\s+test\b", + r"(^|[\s;&|])cargo\s+test\b", + r"(^|[\s;&|])mvn\b.*\b(?:test|verify)\b", + r"(^|[\s;&|])(?:gradle|gradlew|./gradlew)\b.*\btest\b", + r"(^|[\s;&|])(?:vitest|jest|mocha|ctest|rspec)\b", + ] +] + +LINT_COMMAND_PATTERNS = [ + re.compile(pattern, re.IGNORECASE) + for pattern in [ + r"(^|[\s;&|])(?:eslint|ruff|pylint|flake8|checkstyle)\b", + r"\b(?:lint|spotless:check|license:check)\b", + ] +] + +TYPECHECK_COMMAND_PATTERNS = [ + re.compile(pattern, re.IGNORECASE) + for pattern in [ + r"\b(?:typecheck|type-check|tsc\s+--noEmit|mypy|pyright)\b", + ] +] + +BUILD_COMMAND_PATTERNS = [ + re.compile(pattern, re.IGNORECASE) + for pattern in [ + r"(^|[\s;&|])(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?build\b", + r"(^|[\s;&|])mvn\b.*\b(?:compile|package|install)\b", + r"(^|[\s;&|])cargo\s+(?:build|check)\b", + r"(^|[\s;&|])go\s+build\b", + ] +] + def redact_text(text): redacted = str(text) @@ -71,6 +129,186 @@ def _json_text(value): return json.dumps(value, ensure_ascii=False, sort_keys=True) +def _tool_tokens(tool_name): + if not tool_name: + return [] + separated = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", str(tool_name)) + return [part for part in re.split(r"[^A-Za-z0-9]+", separated.lower()) if part] + + +def _has_token(tokens, values): + return any(token in values for token in tokens) + + +def _first_string(mapping, keys): + if not isinstance(mapping, dict): + return None + for key in keys: + value = mapping.get(key) + if isinstance(value, str) and value.strip(): + return value + return None + + +def _path_values(tool_input): + if not isinstance(tool_input, dict): + return [] + values = [] + for key in PATH_KEYS: + value = tool_input.get(key) + if isinstance(value, str) and value.strip(): + values.append(value) + for key in PATH_LIST_KEYS: + value = tool_input.get(key) + if isinstance(value, list): + values.extend(item for item in value if isinstance(item, str) and item.strip()) + deduped = [] + seen = set() + for value in values: + normalized = value.strip() + if normalized not in seen: + seen.add(normalized) + deduped.append(normalized) + return deduped + + +def _validation_type(command): + if not command: + return None + if any(pattern.search(command) for pattern in TEST_COMMAND_PATTERNS): + return "test" + if any(pattern.search(command) for pattern in LINT_COMMAND_PATTERNS): + return "lint" + if any(pattern.search(command) for pattern in TYPECHECK_COMMAND_PATTERNS): + return "typecheck" + if any(pattern.search(command) for pattern in BUILD_COMMAND_PATTERNS): + return "build" + return None + + +def _tool_operation(tokens): + if "multi" in tokens and "edit" in tokens: + return "multi_edit" + for operation in ["read", "view", "open", "edit", "write", "patch", "replace", "update"]: + if operation in tokens: + return operation + return None + + +def _tool_normalization(tool_name, tool_input): + tokens = _tool_tokens(tool_name) + metadata = {"normalizationVersion": NORMALIZATION_VERSION} + command = _first_string(tool_input, COMMAND_KEYS) + paths = _path_values(tool_input) + + if _has_token(tokens, {"bash", "shell", "exec", "run", "command"}) or command: + validation_type = _validation_type(command) + if validation_type: + metadata["validationType"] = validation_type + metadata["toolCategory"] = "command" + return { + "kind": "test_result" if validation_type == "test" else "command", + "command": command, + "operation": "run", + "metadata": metadata, + } + + if _has_token(tokens, {"read", "view", "open"}) and not _has_token(tokens, {"thread"}): + metadata["toolCategory"] = "file" + return { + "kind": "file_read", + "path": paths[0] if paths else None, + "operation": "read", + "metadata": _with_paths(metadata, paths), + } + + if _has_token(tokens, {"edit", "write", "patch", "replace", "update"}): + metadata["toolCategory"] = "file" + return { + "kind": "file_edit", + "path": paths[0] if paths else None, + "operation": _tool_operation(tokens) or "edit", + "metadata": _with_paths(metadata, paths), + } + + if _has_token(tokens, {"web", "fetch", "http"}): + metadata["toolCategory"] = "web_search" if "search" in tokens else "web_fetch" + url = _first_string(tool_input, URL_KEYS) + query = _first_string(tool_input, ["query"]) + if url: + metadata["url"] = url + if query: + metadata["query"] = query + return {"kind": "tool_result", "operation": metadata["toolCategory"], "metadata": metadata} + + if _has_token(tokens, {"grep", "glob", "search", "find", "rg"}): + metadata["toolCategory"] = "search" + pattern = _first_string(tool_input, SEARCH_PATTERN_KEYS) + if pattern: + metadata["searchPattern"] = pattern + return { + "kind": "tool_result", + "path": paths[0] if paths else None, + "operation": "search", + "metadata": _with_paths(metadata, paths), + } + + if _has_token(tokens, {"ls", "list"}): + metadata["toolCategory"] = "list" + return { + "kind": "tool_result", + "path": paths[0] if paths else None, + "operation": "list", + "metadata": _with_paths(metadata, paths), + } + + if _has_token(tokens, {"todo"}): + metadata["toolCategory"] = "todo" + return {"kind": "tool_result", "operation": "todo", "metadata": metadata} + + if _has_token(tokens, {"task", "agent", "subagent"}): + metadata["toolCategory"] = "subagent" + return {"kind": "tool_result", "operation": "subagent", "metadata": metadata} + + metadata["toolCategory"] = "unknown" + return { + "kind": "tool_result", + "path": paths[0] if paths else None, + "operation": "unknown", + "metadata": _with_paths(metadata, paths), + } + + +def _with_paths(metadata, paths): + if len(paths) > 1: + metadata = dict(metadata) + metadata["paths"] = paths + return metadata + + +def _redact_metadata(metadata): + redacted = {} + redaction_kinds = [] + for key, value in metadata.items(): + if isinstance(value, str): + item, kinds = redact_text(value) + redacted[key] = item + redaction_kinds.extend(kinds) + elif isinstance(value, list): + items = [] + for item in value: + if isinstance(item, str): + redacted_item, kinds = redact_text(item) + items.append(redacted_item) + redaction_kinds.extend(kinds) + else: + items.append(item) + redacted[key] = items + else: + redacted[key] = value + return redacted, sorted(set(redaction_kinds)) + + def event_id(source_client, session_id, seq, hook_input, kind=None, text=None): hook_name = hook_input.get("hook_event_name") or "" tool_name = hook_input.get("tool_name") or "" @@ -153,24 +391,34 @@ def normalize_hook_event(hook_input, seq, turn_id=None, turn_seq=None): source_client = hook_input.get("source_client") or "claude-code" session_id = hook_input.get("session_id") or "unknown-session" tool_name = hook_input.get("tool_name") - tool_input = hook_input.get("tool_input") or {} - tool_response = hook_input.get("tool_response") or {} - exit_code = tool_response.get("exit_code") + raw_tool_input = hook_input.get("tool_input") + raw_tool_response = hook_input.get("tool_response") + tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {} + tool_response = raw_tool_response if isinstance(raw_tool_response, dict) else {} + exit_code = tool_response.get("exit_code") if isinstance(tool_response, dict) else None redaction_kinds = [] + normalization = _tool_normalization(tool_name, tool_input) + event_kind = normalization["kind"] event = { - "eventId": event_id(source_client, session_id, seq, hook_input, _event_kind(tool_name)), + "eventId": event_id(source_client, session_id, seq, hook_input, event_kind), "seq": seq, - "kind": _event_kind(tool_name), + "kind": event_kind, "occurredAt": hook_input.get("timestamp"), "toolName": tool_name, } - if tool_name == "Bash" and isinstance(tool_input, dict): - command, kinds = redact_text(tool_input.get("command") or "") + if normalization.get("path"): + path, kinds = redact_text(normalization["path"]) + event["path"] = path + redaction_kinds.extend(kinds) + if normalization.get("operation"): + event["operation"] = normalization["operation"] + if normalization.get("command") is not None: + command, kinds = redact_text(normalization.get("command") or "") event["command"] = command redaction_kinds.extend(kinds) else: - redacted_input, kinds = _redact_value(tool_input) + redacted_input, kinds = _redact_value(raw_tool_input if raw_tool_input is not None else {}) event["input"] = _json_text(redacted_input) redaction_kinds.extend(kinds) @@ -180,17 +428,23 @@ def normalize_hook_event(hook_input, seq, turn_id=None, turn_seq=None): else: event["status"] = "success" if hook_input.get("hook_event_name") == "PostToolUse" else "running" - output = { - key: value - for key, value in tool_response.items() - if key not in {"exit_code", "exitCode"} and value is not None - } + if isinstance(raw_tool_response, dict): + output = { + key: value + for key, value in raw_tool_response.items() + if key not in {"exit_code", "exitCode"} and value is not None + } + else: + output = raw_tool_response if output: redacted_output, kinds = _redact_value(output) event["output"] = _json_text(redacted_output) redaction_kinds.extend(kinds) metadata = {"hookEventName": hook_input.get("hook_event_name")} + normalization_metadata, kinds = _redact_metadata(normalization.get("metadata") or {}) + metadata.update(normalization_metadata) + redaction_kinds.extend(kinds) if turn_id: metadata["turnId"] = turn_id if turn_seq is not None: @@ -202,12 +456,6 @@ def normalize_hook_event(hook_input, seq, turn_id=None, turn_seq=None): return {key: value for key, value in event.items() if value is not None} -def _event_kind(tool_name): - if tool_name == "Bash": - return "command" - return "tool_result" - - def append_event(state, event): state.append_agent_event(event) diff --git a/memind-integrations/claude-code/tests/test_agent_timeline.py b/memind-integrations/claude-code/tests/test_agent_timeline.py index 03ba0a1e..dfe097e1 100644 --- a/memind-integrations/claude-code/tests/test_agent_timeline.py +++ b/memind-integrations/claude-code/tests/test_agent_timeline.py @@ -25,7 +25,7 @@ class AgentTimelineTest(unittest.TestCase): - def test_normalizes_post_tool_use_to_command_event(self): + def test_normalizes_post_tool_use_to_test_result_event(self): event = normalize_hook_event( { "hook_event_name": "PostToolUse", @@ -40,7 +40,7 @@ def test_normalizes_post_tool_use_to_command_event(self): turn_seq=1, ) - self.assertEqual(event["kind"], "command") + self.assertEqual(event["kind"], "test_result") self.assertIn("eventId", event) self.assertNotIn("id", event) self.assertEqual(event["seq"], 1) @@ -48,9 +48,140 @@ def test_normalizes_post_tool_use_to_command_event(self): self.assertEqual(event["status"], "failed") self.assertEqual(event["exitCode"], 1) self.assertEqual(event["output"], '{"stdout": "rounding mismatch"}') + self.assertEqual(event["metadata"]["validationType"], "test") + self.assertEqual(event["metadata"]["normalizationVersion"], 1) self.assertEqual(event["metadata"]["turnId"], "s-turn-1") self.assertEqual(event["metadata"]["turnSeq"], 1) + def test_normalizes_non_test_bash_to_command_event(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "Bash", + "tool_input": {"command": "git status --short"}, + "tool_response": {"exit_code": 0, "stdout": ""}, + "timestamp": "2026-05-24T10:00:00Z", + }, + seq=1, + ) + + self.assertEqual(event["kind"], "command") + self.assertEqual(event["command"], "git status --short") + self.assertEqual(event["status"], "success") + self.assertEqual(event["metadata"]["normalizationVersion"], 1) + + def test_normalizes_file_read_tool_with_path(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "Read", + "tool_input": {"file_path": "src/payment/calc.ts"}, + "tool_response": {"content": "export function calc() {}"}, + "timestamp": "2026-05-24T10:01:00Z", + }, + seq=2, + ) + + self.assertEqual(event["kind"], "file_read") + self.assertEqual(event["path"], "src/payment/calc.ts") + self.assertEqual(event["operation"], "read") + self.assertEqual(event["status"], "success") + self.assertEqual(event["metadata"]["toolCategory"], "file") + + def test_normalizes_file_edit_tool_with_path_and_operation(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "MultiEdit", + "tool_input": {"file_path": "src/payment/calc.ts", "edits": []}, + "tool_response": {"result": "ok"}, + "timestamp": "2026-05-24T10:02:00Z", + }, + seq=3, + ) + + self.assertEqual(event["kind"], "file_edit") + self.assertEqual(event["path"], "src/payment/calc.ts") + self.assertEqual(event["operation"], "multi_edit") + self.assertEqual(event["status"], "success") + self.assertEqual(event["metadata"]["toolCategory"], "file") + + def test_preserves_search_tool_as_tool_result_with_search_metadata(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "Grep", + "tool_input": {"pattern": "AgentEpisodeAssembler", "path": "memind-plugins"}, + "tool_response": {"matches": ["AgentEpisodeAssembler.java"]}, + "timestamp": "2026-05-24T10:03:00Z", + }, + seq=4, + ) + + self.assertEqual(event["kind"], "tool_result") + self.assertEqual(event["path"], "memind-plugins") + self.assertEqual(event["operation"], "search") + self.assertEqual(event["metadata"]["toolCategory"], "search") + self.assertEqual(event["metadata"]["searchPattern"], "AgentEpisodeAssembler") + + def test_normalizes_web_search_before_generic_search(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "WebSearch", + "tool_input": {"query": "OpenMemind rawdata-agent"}, + "tool_response": {"results": []}, + "timestamp": "2026-05-24T10:03:30Z", + }, + seq=5, + ) + + self.assertEqual(event["kind"], "tool_result") + self.assertEqual(event["operation"], "web_search") + self.assertEqual(event["metadata"]["toolCategory"], "web_search") + self.assertEqual(event["metadata"]["query"], "OpenMemind rawdata-agent") + + def test_unknown_tool_keeps_raw_payload_and_extracts_path_when_available(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "CustomAnalyzer", + "tool_input": {"target_file": "src/main/java/Foo.java", "mode": "deep"}, + "tool_response": {"summary": "ok"}, + "timestamp": "2026-05-24T10:04:00Z", + }, + seq=5, + ) + + self.assertEqual(event["kind"], "tool_result") + self.assertEqual(event["path"], "src/main/java/Foo.java") + self.assertEqual(event["operation"], "unknown") + self.assertIn('"mode": "deep"', event["input"]) + self.assertEqual(event["metadata"]["toolCategory"], "unknown") + + def test_unknown_tool_keeps_non_object_raw_input_and_output(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "CustomTool", + "tool_input": "raw input text", + "tool_response": "raw output text", + "timestamp": "2026-05-24T10:05:00Z", + }, + seq=6, + ) + + self.assertEqual(event["kind"], "tool_result") + self.assertEqual(event["input"], "raw input text") + self.assertEqual(event["output"], "raw output text") + def test_normalizes_user_prompt_and_stop_events_with_turn_metadata(self): prompt_event = normalize_user_prompt_event( { @@ -128,7 +259,7 @@ def test_builds_agent_timeline_payload(self): "hook_event_name": "PostToolUse", "session_id": "s", "tool_name": "Bash", - "tool_input": {"command": "npm test payment"}, + "tool_input": {"command": "git status --short"}, "tool_response": {"exit_code": 0}, "timestamp": "2026-05-24T10:00:00Z", }, diff --git a/memind-integrations/claude-code/tests/test_hooks.py b/memind-integrations/claude-code/tests/test_hooks.py index 9eeaba65..8c846f63 100644 --- a/memind-integrations/claude-code/tests/test_hooks.py +++ b/memind-integrations/claude-code/tests/test_hooks.py @@ -157,7 +157,7 @@ def test_pre_tool_use_fails_open(self): self.assertEqual(output, {"continue": True}) state_file = next(state_dir.glob("*.json")) event = json.loads(state_file.read_text())["agentEvents"][0] - self.assertEqual(event["kind"], "command") + self.assertEqual(event["kind"], "test_result") self.assertEqual(event["status"], "running") def test_post_tool_use_fails_open(self): @@ -183,7 +183,7 @@ def test_post_tool_use_fails_open(self): self.assertEqual(output, {"continue": True}) state_file = next(state_dir.glob("*.json")) event = json.loads(state_file.read_text())["agentEvents"][0] - self.assertEqual(event["kind"], "command") + self.assertEqual(event["kind"], "test_result") self.assertEqual(event["status"], "success") def test_retrieve_buffers_user_prompt_event_before_memory_lookup(self): diff --git a/memind-integrations/codex/scripts/lib/agent_timeline.py b/memind-integrations/codex/scripts/lib/agent_timeline.py index 80bc836b..c3a18333 100644 --- a/memind-integrations/codex/scripts/lib/agent_timeline.py +++ b/memind-integrations/codex/scripts/lib/agent_timeline.py @@ -19,6 +19,7 @@ MAX_TEXT_CHARS = 4000 +NORMALIZATION_VERSION = 1 SECRET_PATTERNS = [ ("openai_key", re.compile(r"sk-[A-Za-z0-9_-]{8,}")), @@ -26,6 +27,63 @@ ("private_key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.DOTALL)), ] +PATH_KEYS = [ + "file_path", + "filepath", + "filePath", + "path", + "file", + "target_file", + "targetFile", + "target_path", + "targetPath", + "notebook_path", + "notebookPath", +] +PATH_LIST_KEYS = ["files", "paths"] +COMMAND_KEYS = ["command", "cmd", "shell_command"] +SEARCH_PATTERN_KEYS = ["pattern", "query", "regex", "glob"] +URL_KEYS = ["url", "uri", "href"] + +TEST_COMMAND_PATTERNS = [ + re.compile(pattern, re.IGNORECASE) + for pattern in [ + r"(^|[\s;&|])(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|vitest|jest)(?:\b|:)", + r"(^|[\s;&|])pytest\b", + r"(^|[\s;&|])python(?:3)?\s+-m\s+unittest\b", + r"(^|[\s;&|])go\s+test\b", + r"(^|[\s;&|])cargo\s+test\b", + r"(^|[\s;&|])mvn\b.*\b(?:test|verify)\b", + r"(^|[\s;&|])(?:gradle|gradlew|./gradlew)\b.*\btest\b", + r"(^|[\s;&|])(?:vitest|jest|mocha|ctest|rspec)\b", + ] +] + +LINT_COMMAND_PATTERNS = [ + re.compile(pattern, re.IGNORECASE) + for pattern in [ + r"(^|[\s;&|])(?:eslint|ruff|pylint|flake8|checkstyle)\b", + r"\b(?:lint|spotless:check|license:check)\b", + ] +] + +TYPECHECK_COMMAND_PATTERNS = [ + re.compile(pattern, re.IGNORECASE) + for pattern in [ + r"\b(?:typecheck|type-check|tsc\s+--noEmit|mypy|pyright)\b", + ] +] + +BUILD_COMMAND_PATTERNS = [ + re.compile(pattern, re.IGNORECASE) + for pattern in [ + r"(^|[\s;&|])(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?build\b", + r"(^|[\s;&|])mvn\b.*\b(?:compile|package|install)\b", + r"(^|[\s;&|])cargo\s+(?:build|check)\b", + r"(^|[\s;&|])go\s+build\b", + ] +] + def redact_text(text): redacted = str(text) @@ -71,6 +129,186 @@ def _json_text(value): return json.dumps(value, ensure_ascii=False, sort_keys=True) +def _tool_tokens(tool_name): + if not tool_name: + return [] + separated = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", str(tool_name)) + return [part for part in re.split(r"[^A-Za-z0-9]+", separated.lower()) if part] + + +def _has_token(tokens, values): + return any(token in values for token in tokens) + + +def _first_string(mapping, keys): + if not isinstance(mapping, dict): + return None + for key in keys: + value = mapping.get(key) + if isinstance(value, str) and value.strip(): + return value + return None + + +def _path_values(tool_input): + if not isinstance(tool_input, dict): + return [] + values = [] + for key in PATH_KEYS: + value = tool_input.get(key) + if isinstance(value, str) and value.strip(): + values.append(value) + for key in PATH_LIST_KEYS: + value = tool_input.get(key) + if isinstance(value, list): + values.extend(item for item in value if isinstance(item, str) and item.strip()) + deduped = [] + seen = set() + for value in values: + normalized = value.strip() + if normalized not in seen: + seen.add(normalized) + deduped.append(normalized) + return deduped + + +def _validation_type(command): + if not command: + return None + if any(pattern.search(command) for pattern in TEST_COMMAND_PATTERNS): + return "test" + if any(pattern.search(command) for pattern in LINT_COMMAND_PATTERNS): + return "lint" + if any(pattern.search(command) for pattern in TYPECHECK_COMMAND_PATTERNS): + return "typecheck" + if any(pattern.search(command) for pattern in BUILD_COMMAND_PATTERNS): + return "build" + return None + + +def _tool_operation(tokens): + if "multi" in tokens and "edit" in tokens: + return "multi_edit" + for operation in ["read", "view", "open", "edit", "write", "patch", "replace", "update"]: + if operation in tokens: + return operation + return None + + +def _tool_normalization(tool_name, tool_input): + tokens = _tool_tokens(tool_name) + metadata = {"normalizationVersion": NORMALIZATION_VERSION} + command = _first_string(tool_input, COMMAND_KEYS) + paths = _path_values(tool_input) + + if _has_token(tokens, {"bash", "shell", "exec", "run", "command"}) or command: + validation_type = _validation_type(command) + if validation_type: + metadata["validationType"] = validation_type + metadata["toolCategory"] = "command" + return { + "kind": "test_result" if validation_type == "test" else "command", + "command": command, + "operation": "run", + "metadata": metadata, + } + + if _has_token(tokens, {"read", "view", "open"}) and not _has_token(tokens, {"thread"}): + metadata["toolCategory"] = "file" + return { + "kind": "file_read", + "path": paths[0] if paths else None, + "operation": "read", + "metadata": _with_paths(metadata, paths), + } + + if _has_token(tokens, {"edit", "write", "patch", "replace", "update"}): + metadata["toolCategory"] = "file" + return { + "kind": "file_edit", + "path": paths[0] if paths else None, + "operation": _tool_operation(tokens) or "edit", + "metadata": _with_paths(metadata, paths), + } + + if _has_token(tokens, {"web", "fetch", "http"}): + metadata["toolCategory"] = "web_search" if "search" in tokens else "web_fetch" + url = _first_string(tool_input, URL_KEYS) + query = _first_string(tool_input, ["query"]) + if url: + metadata["url"] = url + if query: + metadata["query"] = query + return {"kind": "tool_result", "operation": metadata["toolCategory"], "metadata": metadata} + + if _has_token(tokens, {"grep", "glob", "search", "find", "rg"}): + metadata["toolCategory"] = "search" + pattern = _first_string(tool_input, SEARCH_PATTERN_KEYS) + if pattern: + metadata["searchPattern"] = pattern + return { + "kind": "tool_result", + "path": paths[0] if paths else None, + "operation": "search", + "metadata": _with_paths(metadata, paths), + } + + if _has_token(tokens, {"ls", "list"}): + metadata["toolCategory"] = "list" + return { + "kind": "tool_result", + "path": paths[0] if paths else None, + "operation": "list", + "metadata": _with_paths(metadata, paths), + } + + if _has_token(tokens, {"todo"}): + metadata["toolCategory"] = "todo" + return {"kind": "tool_result", "operation": "todo", "metadata": metadata} + + if _has_token(tokens, {"task", "agent", "subagent"}): + metadata["toolCategory"] = "subagent" + return {"kind": "tool_result", "operation": "subagent", "metadata": metadata} + + metadata["toolCategory"] = "unknown" + return { + "kind": "tool_result", + "path": paths[0] if paths else None, + "operation": "unknown", + "metadata": _with_paths(metadata, paths), + } + + +def _with_paths(metadata, paths): + if len(paths) > 1: + metadata = dict(metadata) + metadata["paths"] = paths + return metadata + + +def _redact_metadata(metadata): + redacted = {} + redaction_kinds = [] + for key, value in metadata.items(): + if isinstance(value, str): + item, kinds = redact_text(value) + redacted[key] = item + redaction_kinds.extend(kinds) + elif isinstance(value, list): + items = [] + for item in value: + if isinstance(item, str): + redacted_item, kinds = redact_text(item) + items.append(redacted_item) + redaction_kinds.extend(kinds) + else: + items.append(item) + redacted[key] = items + else: + redacted[key] = value + return redacted, sorted(set(redaction_kinds)) + + def event_id(source_client, session_id, seq, hook_input, kind=None, text=None): hook_name = hook_input.get("hook_event_name") or "" tool_name = hook_input.get("tool_name") or "" @@ -153,24 +391,34 @@ def normalize_hook_event(hook_input, seq, turn_id=None, turn_seq=None): source_client = hook_input.get("source_client") or "codex" session_id = hook_input.get("session_id") or "unknown-session" tool_name = hook_input.get("tool_name") - tool_input = hook_input.get("tool_input") or {} - tool_response = hook_input.get("tool_response") or {} - exit_code = tool_response.get("exit_code") + raw_tool_input = hook_input.get("tool_input") + raw_tool_response = hook_input.get("tool_response") + tool_input = raw_tool_input if isinstance(raw_tool_input, dict) else {} + tool_response = raw_tool_response if isinstance(raw_tool_response, dict) else {} + exit_code = tool_response.get("exit_code") if isinstance(tool_response, dict) else None redaction_kinds = [] + normalization = _tool_normalization(tool_name, tool_input) + event_kind = normalization["kind"] event = { - "eventId": event_id(source_client, session_id, seq, hook_input, _event_kind(tool_name)), + "eventId": event_id(source_client, session_id, seq, hook_input, event_kind), "seq": seq, - "kind": _event_kind(tool_name), + "kind": event_kind, "occurredAt": hook_input.get("timestamp"), "toolName": tool_name, } - if tool_name == "Bash" and isinstance(tool_input, dict): - command, kinds = redact_text(tool_input.get("command") or "") + if normalization.get("path"): + path, kinds = redact_text(normalization["path"]) + event["path"] = path + redaction_kinds.extend(kinds) + if normalization.get("operation"): + event["operation"] = normalization["operation"] + if normalization.get("command") is not None: + command, kinds = redact_text(normalization.get("command") or "") event["command"] = command redaction_kinds.extend(kinds) else: - redacted_input, kinds = _redact_value(tool_input) + redacted_input, kinds = _redact_value(raw_tool_input if raw_tool_input is not None else {}) event["input"] = _json_text(redacted_input) redaction_kinds.extend(kinds) @@ -180,17 +428,23 @@ def normalize_hook_event(hook_input, seq, turn_id=None, turn_seq=None): else: event["status"] = "success" if hook_input.get("hook_event_name") == "PostToolUse" else "running" - output = { - key: value - for key, value in tool_response.items() - if key not in {"exit_code", "exitCode"} and value is not None - } + if isinstance(raw_tool_response, dict): + output = { + key: value + for key, value in raw_tool_response.items() + if key not in {"exit_code", "exitCode"} and value is not None + } + else: + output = raw_tool_response if output: redacted_output, kinds = _redact_value(output) event["output"] = _json_text(redacted_output) redaction_kinds.extend(kinds) metadata = {"hookEventName": hook_input.get("hook_event_name")} + normalization_metadata, kinds = _redact_metadata(normalization.get("metadata") or {}) + metadata.update(normalization_metadata) + redaction_kinds.extend(kinds) if turn_id: metadata["turnId"] = turn_id if turn_seq is not None: @@ -202,12 +456,6 @@ def normalize_hook_event(hook_input, seq, turn_id=None, turn_seq=None): return {key: value for key, value in event.items() if value is not None} -def _event_kind(tool_name): - if tool_name == "Bash": - return "command" - return "tool_result" - - def append_event(state, event): state.append_agent_event(event) diff --git a/memind-integrations/codex/tests/test_agent_timeline.py b/memind-integrations/codex/tests/test_agent_timeline.py index 6014178c..e917224d 100644 --- a/memind-integrations/codex/tests/test_agent_timeline.py +++ b/memind-integrations/codex/tests/test_agent_timeline.py @@ -25,7 +25,7 @@ class AgentTimelineTest(unittest.TestCase): - def test_normalizes_post_tool_use_to_command_event(self): + def test_normalizes_post_tool_use_to_test_result_event(self): event = normalize_hook_event( { "hook_event_name": "PostToolUse", @@ -41,7 +41,7 @@ def test_normalizes_post_tool_use_to_command_event(self): turn_seq=1, ) - self.assertEqual(event["kind"], "command") + self.assertEqual(event["kind"], "test_result") self.assertIn("eventId", event) self.assertNotIn("id", event) self.assertEqual(event["seq"], 1) @@ -49,9 +49,147 @@ def test_normalizes_post_tool_use_to_command_event(self): self.assertEqual(event["status"], "failed") self.assertEqual(event["exitCode"], 1) self.assertEqual(event["output"], '{"stderr": "rounding mismatch"}') + self.assertEqual(event["metadata"]["validationType"], "test") + self.assertEqual(event["metadata"]["normalizationVersion"], 1) self.assertEqual(event["metadata"]["turnId"], "s-turn-1") self.assertEqual(event["metadata"]["turnSeq"], 1) + def test_normalizes_non_test_bash_to_command_event(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "Bash", + "tool_input": {"command": "git status --short"}, + "tool_response": {"exit_code": 0, "stdout": ""}, + "timestamp": "2026-05-24T10:00:00Z", + "source_client": "codex", + }, + seq=1, + ) + + self.assertEqual(event["kind"], "command") + self.assertEqual(event["command"], "git status --short") + self.assertEqual(event["status"], "success") + self.assertEqual(event["metadata"]["normalizationVersion"], 1) + + def test_normalizes_file_read_tool_with_path(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "Read", + "tool_input": {"file_path": "src/payment/calc.ts"}, + "tool_response": {"content": "export function calc() {}"}, + "timestamp": "2026-05-24T10:01:00Z", + "source_client": "codex", + }, + seq=2, + ) + + self.assertEqual(event["kind"], "file_read") + self.assertEqual(event["path"], "src/payment/calc.ts") + self.assertEqual(event["operation"], "read") + self.assertEqual(event["status"], "success") + self.assertEqual(event["metadata"]["toolCategory"], "file") + + def test_normalizes_file_edit_tool_with_path_and_operation(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "MultiEdit", + "tool_input": {"file_path": "src/payment/calc.ts", "edits": []}, + "tool_response": {"result": "ok"}, + "timestamp": "2026-05-24T10:02:00Z", + "source_client": "codex", + }, + seq=3, + ) + + self.assertEqual(event["kind"], "file_edit") + self.assertEqual(event["path"], "src/payment/calc.ts") + self.assertEqual(event["operation"], "multi_edit") + self.assertEqual(event["status"], "success") + self.assertEqual(event["metadata"]["toolCategory"], "file") + + def test_preserves_search_tool_as_tool_result_with_search_metadata(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "Grep", + "tool_input": {"pattern": "AgentEpisodeAssembler", "path": "memind-plugins"}, + "tool_response": {"matches": ["AgentEpisodeAssembler.java"]}, + "timestamp": "2026-05-24T10:03:00Z", + "source_client": "codex", + }, + seq=4, + ) + + self.assertEqual(event["kind"], "tool_result") + self.assertEqual(event["path"], "memind-plugins") + self.assertEqual(event["operation"], "search") + self.assertEqual(event["metadata"]["toolCategory"], "search") + self.assertEqual(event["metadata"]["searchPattern"], "AgentEpisodeAssembler") + + def test_normalizes_web_search_before_generic_search(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "WebSearch", + "tool_input": {"query": "OpenMemind rawdata-agent"}, + "tool_response": {"results": []}, + "timestamp": "2026-05-24T10:03:30Z", + "source_client": "codex", + }, + seq=5, + ) + + self.assertEqual(event["kind"], "tool_result") + self.assertEqual(event["operation"], "web_search") + self.assertEqual(event["metadata"]["toolCategory"], "web_search") + self.assertEqual(event["metadata"]["query"], "OpenMemind rawdata-agent") + + def test_unknown_tool_keeps_raw_payload_and_extracts_path_when_available(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "CustomAnalyzer", + "tool_input": {"target_file": "src/main/java/Foo.java", "mode": "deep"}, + "tool_response": {"summary": "ok"}, + "timestamp": "2026-05-24T10:04:00Z", + "source_client": "codex", + }, + seq=5, + ) + + self.assertEqual(event["kind"], "tool_result") + self.assertEqual(event["path"], "src/main/java/Foo.java") + self.assertEqual(event["operation"], "unknown") + self.assertIn('"mode": "deep"', event["input"]) + self.assertEqual(event["metadata"]["toolCategory"], "unknown") + + def test_unknown_tool_keeps_non_object_raw_input_and_output(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "CustomTool", + "tool_input": "raw input text", + "tool_response": "raw output text", + "timestamp": "2026-05-24T10:05:00Z", + "source_client": "codex", + }, + seq=6, + ) + + self.assertEqual(event["kind"], "tool_result") + self.assertEqual(event["input"], "raw input text") + self.assertEqual(event["output"], "raw output text") + def test_normalizes_user_prompt_and_stop_events_with_turn_metadata(self): prompt_event = normalize_user_prompt_event( { @@ -128,7 +266,7 @@ def test_builds_agent_timeline_payload(self): "hook_event_name": "PostToolUse", "session_id": "s", "tool_name": "Bash", - "tool_input": {"command": "cargo test"}, + "tool_input": {"command": "git status --short"}, "tool_response": {"exit_code": 0}, "source_client": "codex", }, diff --git a/memind-integrations/codex/tests/test_hooks.py b/memind-integrations/codex/tests/test_hooks.py index e3e1499b..a86b309f 100644 --- a/memind-integrations/codex/tests/test_hooks.py +++ b/memind-integrations/codex/tests/test_hooks.py @@ -155,7 +155,7 @@ def test_pre_tool_use_fails_open_and_buffers_event(self): self.assertEqual(output, {"continue": True}) state_file = next(state_dir.glob("*.json")) event = json.loads(state_file.read_text())["agentEvents"][0] - self.assertEqual(event["kind"], "command") + self.assertEqual(event["kind"], "test_result") self.assertEqual(event["status"], "running") def test_post_tool_use_fails_open_and_buffers_event(self): @@ -181,7 +181,7 @@ def test_post_tool_use_fails_open_and_buffers_event(self): self.assertEqual(output, {"continue": True}) state_file = next(state_dir.glob("*.json")) event = json.loads(state_file.read_text())["agentEvents"][0] - self.assertEqual(event["kind"], "command") + self.assertEqual(event["kind"], "test_result") self.assertEqual(event["status"], "success") def test_retrieve_buffers_user_prompt_event_before_memory_lookup(self): diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java index a5db4d02..798854e0 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java @@ -48,6 +48,108 @@ void shouldAssembleSuccessfulPaymentEpisodeWithStableEvidence() { .isEqualTo(new AgentEpisodeAssembler().assemble(timeline).getFirst().id()); } + @Test + void shouldUseNormalizedFileAndTestEventsInEpisodeMetadata() { + List events = + List.of( + AgentEpisodeTestSupport.event( + "e1", + 1, + AgentEventKind.USER_PROMPT, + "2026-05-24T10:00:00Z", + "Fix payment tests", + null, + null, + AgentEventStatus.SUCCESS, + null, + null, + null, + null), + AgentEpisodeTestSupport.event( + "e2", + 2, + AgentEventKind.FILE_READ, + "2026-05-24T10:01:00Z", + null, + "Read", + null, + AgentEventStatus.SUCCESS, + "src/payment/calc.ts", + "read", + null, + null), + AgentEpisodeTestSupport.event( + "e3", + 3, + AgentEventKind.FILE_EDIT, + "2026-05-24T10:02:00Z", + null, + "MultiEdit", + null, + AgentEventStatus.SUCCESS, + "src/payment/calc.ts", + "multi_edit", + null, + null), + AgentEpisodeTestSupport.event( + "e4", + 4, + AgentEventKind.TEST_RESULT, + "2026-05-24T10:03:00Z", + null, + "Bash", + "rounding mismatch", + AgentEventStatus.FAILED, + null, + "run", + "npm test payment", + 1), + AgentEpisodeTestSupport.event( + "e5", + 5, + AgentEventKind.TEST_RESULT, + "2026-05-24T10:04:00Z", + null, + "Bash", + "passed", + AgentEventStatus.SUCCESS, + null, + "run", + "npm test payment", + 0), + AgentEpisodeTestSupport.event( + "e6", + 6, + AgentEventKind.STOP, + "2026-05-24T10:05:00Z", + null, + null, + null, + AgentEventStatus.SUCCESS, + null, + null, + null, + null)); + + List episodes = + new AgentEpisodeAssembler() + .assemble(AgentEpisodeTestSupport.paymentTimeline(events)); + + assertThat(episodes).hasSize(1); + AgentEpisode episode = episodes.getFirst(); + assertThat(episode.outcome()).isEqualTo(AgentOutcome.SUCCESS); + assertThat(episode.files()).containsExactly("src/payment/calc.ts"); + assertThat(episode.fileReferences()) + .extracting("eventId", "path", "operation") + .containsExactly( + org.assertj.core.groups.Tuple.tuple("e2", "src/payment/calc.ts", "read"), + org.assertj.core.groups.Tuple.tuple( + "e3", "src/payment/calc.ts", "multi_edit")); + assertThat(episode.commands()).containsExactly("npm test payment"); + assertThat(episode.commandEvents()).hasSize(2); + assertThat(episode.failureSignals()).contains("rounding mismatch"); + } + @Test void shouldClosePreviousEpisodeWhenNewUserPromptAppears() { var events = new ArrayList<>(AgentEpisodeTestSupport.paymentEvents()); From 2ce9432debac7bb8255eef9a285c80b3a3c801a1 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Tue, 26 May 2026 18:08:14 +0800 Subject: [PATCH 25/54] feat: strengthen coding agent timeline ingestion --- .../2026-05-26-agent-hook-journal-pipeline.md | 1635 +++++++++++++++++ memind-integrations/claude-code/README.md | 45 +- .../claude-code/hooks/hooks.json | 24 + .../claude-code/scripts/ingest.py | 27 +- .../claude-code/scripts/lib/agent_timeline.py | 82 +- .../claude-code/scripts/lib/config.py | 2 - .../claude-code/scripts/lib/identity.py | 5 +- .../claude-code/scripts/lib/state.py | 9 + .../claude-code/scripts/notification.py | 52 + .../claude-code/scripts/subagent_stop.py | 52 + memind-integrations/claude-code/settings.json | 1 - .../claude-code/tests/test_agent_timeline.py | 85 + .../claude-code/tests/test_config.py | 2 + .../claude-code/tests/test_hooks.py | 140 ++ .../claude-code/tests/test_identity.py | 10 +- .../claude-code/tests/test_manifest.py | 7 + .../claude-code/tests/test_state.py | 28 + memind-integrations/codex/README.md | 30 +- .../codex/scripts/lib/agent_timeline.py | 28 +- .../codex/scripts/lib/config.py | 2 - .../codex/scripts/lib/identity.py | 5 +- .../codex/scripts/lib/state.py | 9 + memind-integrations/codex/settings.json | 1 - .../codex/tests/test_agent_timeline.py | 45 + .../codex/tests/test_config.py | 2 + memind-integrations/codex/tests/test_hooks.py | 1 + .../codex/tests/test_identity.py | 10 +- .../codex/tests/test_installer.py | 12 + .../codex/tests/test_manifest.py | 5 + memind-integrations/codex/tests/test_state.py | 29 + .../agent/caption/AgentCaptionGenerator.java | 32 +- .../agent/chunk/AgentEpisodeAssembler.java | 4 + .../item/AgentItemExtractionStrategy.java | 15 +- .../rawdata/agent/model/AgentEventKind.java | 7 + .../caption/AgentCaptionGeneratorTest.java | 59 + .../chunk/AgentEpisodeAssemblerTest.java | 177 ++ .../content/AgentTimelineContentTest.java | 78 + .../AgentItemExtractionStrategyLlmTest.java | 96 + .../item/AgentItemExtractionStrategyTest.java | 104 +- 39 files changed, 2890 insertions(+), 67 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-26-agent-hook-journal-pipeline.md create mode 100644 memind-integrations/claude-code/scripts/notification.py create mode 100644 memind-integrations/claude-code/scripts/subagent_stop.py diff --git a/docs/superpowers/plans/2026-05-26-agent-hook-journal-pipeline.md b/docs/superpowers/plans/2026-05-26-agent-hook-journal-pipeline.md new file mode 100644 index 00000000..9bd40eb4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-agent-hook-journal-pipeline.md @@ -0,0 +1,1635 @@ +# Agent Hook Journal Pipeline Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Strengthen Memind's Claude Code and Codex integrations so coding-agent hook activity is durably captured, project-isolated by `agentId`, flushed as high-quality `agent_timeline` rawdata, and extracted by `rawdata-agent` without adding project/session concepts to Memind core. + +**Architecture:** Claude Code and Codex remain adapter layers: they normalize host-specific hook payloads into Memind `agent_event` records, persist them in a local durable journal, and flush bounded turn timelines on reliable lifecycle boundaries. The `rawdata-agent` plugin remains the canonical parser/extractor: it accepts `agent_timeline`, assembles `agent_episode` segments, generates captions, and extracts memory items through existing Memind item, insight, and graph capabilities. + +**Tech Stack:** Python 3.10+ hook scripts and unit tests, Java 21 rawdata-agent plugin, Maven, Jackson, Reactor, Memind RawData/Item/Insight/Graph APIs, Claude Code hooks, Codex hooks. + +--- + +## Scope + +This plan covers the next implementation phase only: + +- Improve hook-to-memory extraction for both `memind-integrations/claude-code` and `memind-integrations/codex`. +- Make Claude Code and Codex identities always project-scoped through `agentId`. +- Keep session, turn, timeline, and event attribution in metadata/raw content only. +- Expand `rawdata-agent` to understand the richer normalized event stream. +- Preserve the current timeline-only ingestion policy for Claude Code and Codex. + +This plan explicitly does not cover: + +- Retrieval Context Compiler changes. Prompt-time memory formatting remains out of scope for this phase. +- A Memind core project model or session model. +- An `observation` core abstraction copied from claude-mem or agentmemory. +- Conversation rawdata ingestion from Claude Code or Codex transcripts. +- LLM gate / skip heuristics before extraction. + +## Design Commitments + +Memind should learn from agentmemory's broad lifecycle capture and claude-mem's product polish, but the implementation must stay idiomatic to Memind: + +- `agent_event` is an adapter-level evidence record, not a memory item. +- `agent_timeline` is the rawdata submitted to Memind. +- `agent_episode` is the Memind rawdata segment used for captioning and item extraction. +- Project isolation uses the existing `userId + agentId` memory boundary. The project-specific suffix is part of `agentId`. +- Session and turn identifiers are metadata that improve attribution and segmentation; they do not become first-class Memind core entities. + +## End-To-End Flow + +```text +Claude Code hook payload +Codex hook payload + -> adapter-specific normalization and privacy cleanup + -> normalized agent_event + -> local durable journal/state + -> Stop / PreCompact / SessionEnd / supported lifecycle flush boundary + -> rawContent.type = "agent_timeline" + -> rawdata-agent plugin + -> agent_episode segment(s) + -> caption, vectorization, item extraction, graph hints, insight tree +``` + +For Claude Code, supported hook events in this phase are: + +```text +SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Notification, Stop, SubagentStop, PreCompact, SessionEnd +``` + +For Codex, supported hook events in this phase are the current adapter-supported set: + +```text +SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop +``` + +Do not register Claude Code-only events in Codex unless Codex explicitly supports them in the local adapter and tests. + +## File Structure + +### Claude Code Integration + +- Modify `memind-integrations/claude-code/scripts/lib/identity.py` + - Always resolve `agentId` to `__`. + - Keep `projectSlug` based on Git remote hash when available, else resolved root path hash. +- Modify `memind-integrations/claude-code/scripts/lib/config.py` + - Remove `agentIdMode` from defaults and environment mappings. +- Modify `memind-integrations/claude-code/settings.json` + - Remove `agentIdMode`. +- Modify `memind-integrations/claude-code/scripts/lib/agent_timeline.py` + - Add normalization helpers for notification, subagent stop, compact boundary, session end, and generic lifecycle events. + - Keep redaction, truncation, file/tool/command normalization, and stable event IDs. +- Modify `memind-integrations/claude-code/scripts/lib/state.py` + - Improve journal semantics for boundary preservation, flushed-event deletion, empty-state cleanup, and buffer truncation metadata. +- Modify `memind-integrations/claude-code/scripts/retrieve.py` + - Continue appending `USER_PROMPT` before retrieval. + - Ensure turn metadata is written consistently. +- Modify `memind-integrations/claude-code/scripts/pre_tool_use.py` + - Continue appending tool-start evidence. +- Modify `memind-integrations/claude-code/scripts/post_tool_use.py` + - Continue appending tool-result evidence. +- Create `memind-integrations/claude-code/scripts/notification.py` + - Append notification evidence where useful. +- Create `memind-integrations/claude-code/scripts/subagent_stop.py` + - Append subagent completion evidence. +- Modify `memind-integrations/claude-code/scripts/pre_compact.py` + - Append `compact_boundary` before flushing. +- Modify `memind-integrations/claude-code/scripts/session_end.py` + - Append `session_end` before flushing. +- Modify `memind-integrations/claude-code/scripts/ingest.py` + - Support explicit flush reasons and boundary events without duplicating stop handling. +- Modify `memind-integrations/claude-code/hooks/hooks.json` + - Register `Notification` and `SubagentStop`. + - Keep existing `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, `Stop`, `SessionEnd`. +- Modify `memind-integrations/claude-code/README.md` + - Document timeline-only ingestion, project-scoped `agentId`, supported hooks, and metadata-only sessions. +- Modify tests under `memind-integrations/claude-code/tests/`. + +### Codex Integration + +- Modify `memind-integrations/codex/scripts/lib/identity.py` + - Always resolve `agentId` to `__`. +- Modify `memind-integrations/codex/scripts/lib/config.py` + - Remove `agentIdMode` from defaults and environment mappings. +- Modify `memind-integrations/codex/settings.json` + - Remove `agentIdMode`. +- Modify `memind-integrations/codex/scripts/lib/agent_timeline.py` + - Keep normalization behavior aligned with Claude Code for the event types Codex can emit. + - Do not add Codex hook scripts or manifest entries for Claude Code-only lifecycle events. + - Generic parsing helpers may exist only when they are used by Codex tests or by shared test fixtures; they are not a Codex hook support commitment. +- Modify `memind-integrations/codex/scripts/lib/state.py` + - Align durable journal behavior with Claude Code while preserving Codex's `state_key(hook_input)` behavior. +- Modify `memind-integrations/codex/scripts/retrieve.py` + - Continue appending `USER_PROMPT` before retrieval. +- Modify `memind-integrations/codex/scripts/pre_tool_use.py` + - Continue appending tool-start evidence. +- Modify `memind-integrations/codex/scripts/post_tool_use.py` + - Continue appending tool-result evidence. +- Modify `memind-integrations/codex/scripts/ingest.py` + - Flush on `Stop` and preserve Codex-specific `sessionKey` retry payloads. +- Modify `memind-integrations/codex/hooks/hooks.json` + - Keep only `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `Stop` unless Codex support is explicitly verified in tests. +- Modify `memind-integrations/codex/scripts/install_codex_hooks.py` + - Ensure installer tests still prove idempotent merging for the supported hook set. +- Modify `memind-integrations/codex/README.md` + - Document project-scoped `agentId`, timeline-only ingestion, supported hook boundaries, and why unsupported Claude Code lifecycle hooks are not registered. +- Modify tests under `memind-integrations/codex/tests/`. + +### rawdata-agent Plugin + +- Modify `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEventKind.java` + - Add richer normalized event kinds. +- Modify `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java` + - Ensure formatting handles new event kinds without dropping evidence. +- Modify `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java` + - Segment by `USER_PROMPT -> ... -> STOP` as the preferred turn boundary. + - Treat compact/session boundaries as terminal boundaries. + - Preserve phase split behavior for oversized episodes. +- Modify `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGenerator.java` + - Include useful subagent, notification, compact, and session-end signals when present. +- Modify `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java` + - Improve deterministic tool/resolution extraction for richer event evidence. +- Modify `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java` + - Ensure LLM items validate `evidenceEventIds` against the episode event IDs. +- Modify tests under `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/`. + +--- + +## Tasks + +### Task 1: Remove `agentIdMode` And Make Agent Identity Always Project-Scoped + +**Files:** +- Modify: `memind-integrations/claude-code/scripts/lib/identity.py` +- Modify: `memind-integrations/claude-code/scripts/lib/config.py` +- Modify: `memind-integrations/claude-code/settings.json` +- Modify: `memind-integrations/claude-code/tests/test_identity.py` +- Modify: `memind-integrations/claude-code/tests/test_manifest.py` +- Modify: `memind-integrations/claude-code/tests/test_config.py` +- Modify: `memind-integrations/codex/scripts/lib/identity.py` +- Modify: `memind-integrations/codex/scripts/lib/config.py` +- Modify: `memind-integrations/codex/settings.json` +- Modify: `memind-integrations/codex/tests/test_identity.py` +- Modify: `memind-integrations/codex/tests/test_manifest.py` +- Modify: `memind-integrations/codex/tests/test_config.py` + +- [ ] **Step 1: Add failing identity tests for Claude Code** + +In `memind-integrations/claude-code/tests/test_identity.py`, replace the `agentIdMode`-dependent test with assertions that `resolve_identity()` always appends the project slug: + +```python +def test_resolve_identity_always_uses_project_slug(self): + with tempfile.TemporaryDirectory() as tmp: + identity = resolve_identity({"agentId": "claude-code"}, {"cwd": tmp}) + self.assertTrue(identity["userId"].startswith("local__")) + self.assertTrue(identity["agentId"].startswith("claude-code__")) + self.assertNotEqual(identity["agentId"], "claude-code") + self.assertNotIn(":", identity["userId"]) + self.assertNotIn(":", identity["agentId"]) + + +def test_agent_id_mode_is_ignored_for_backward_safety(self): + with tempfile.TemporaryDirectory() as tmp: + identity = resolve_identity( + {"agentId": "claude-code", "agentIdMode": "global"}, + {"cwd": tmp}, + ) + self.assertTrue(identity["agentId"].startswith("claude-code__")) +``` + +- [ ] **Step 2: Add failing identity tests for Codex** + +In `memind-integrations/codex/tests/test_identity.py`, mirror the Claude Code assertions with base agent `codex`: + +```python +def test_resolve_identity_always_uses_project_slug(self): + with tempfile.TemporaryDirectory() as tmp: + identity = resolve_identity({"agentId": "codex", "userId": "u"}, {"cwd": tmp}) + self.assertEqual(identity["userId"], "u") + self.assertTrue(identity["agentId"].startswith("codex__")) + self.assertNotEqual(identity["agentId"], "codex") + + +def test_agent_id_mode_is_ignored_for_backward_safety(self): + with tempfile.TemporaryDirectory() as tmp: + identity = resolve_identity( + {"agentId": "codex", "agentIdMode": "global", "userId": "u"}, + {"cwd": tmp}, + ) + self.assertTrue(identity["agentId"].startswith("codex__")) +``` + +- [ ] **Step 3: Add failing config and manifest tests** + +In both `test_manifest.py` files, assert default settings do not expose `agentIdMode`: + +```python +self.assertNotIn("agentIdMode", settings) +``` + +In both `test_config.py` files, assert loaded config does not include `agentIdMode` by default and `MEMIND_AGENT_ID_MODE` is no longer accepted as a documented override: + +```python +self.assertNotIn("agentIdMode", config) +``` + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_identity.py \ + memind-integrations/claude-code/tests/test_manifest.py \ + memind-integrations/claude-code/tests/test_config.py \ + memind-integrations/codex/tests/test_identity.py \ + memind-integrations/codex/tests/test_manifest.py \ + memind-integrations/codex/tests/test_config.py +``` + +Expected: tests fail because `agentIdMode` still exists and `resolve_identity()` can still return the base agent ID. + +- [ ] **Step 4: Update identity implementation** + +In both `identity.py` files, change `resolve_identity()` to: + +```python +def resolve_identity(config, hook_input): + cwd = hook_input.get("cwd") or os.getcwd() + user_id = config.get("userId") or f"local{SEPARATOR}{getpass.getuser()}" + base_agent = config.get("agentId") or "claude-code" # use "codex" in the Codex file + agent_id = f"{base_agent}{SEPARATOR}{project_slug(cwd)}" + return {"userId": user_id, "agentId": agent_id} +``` + +Keep `_git_remote()`, `_hash()`, and `project_slug()` unchanged unless tests reveal a real bug. + +- [ ] **Step 5: Remove configuration surface** + +In both `config.py` files: + +- Remove `"agentIdMode": "project"` from `DEFAULT_SETTINGS`. +- Remove `"MEMIND_AGENT_ID_MODE": ("agentIdMode", str)` from `ENV_MAP`. + +In both `settings.json` files: + +- Remove the `agentIdMode` property. + +- [ ] **Step 6: Run tests** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_identity.py \ + memind-integrations/claude-code/tests/test_manifest.py \ + memind-integrations/claude-code/tests/test_config.py \ + memind-integrations/codex/tests/test_identity.py \ + memind-integrations/codex/tests/test_manifest.py \ + memind-integrations/codex/tests/test_config.py +``` + +Expected: all selected tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add \ + memind-integrations/claude-code/scripts/lib/identity.py \ + memind-integrations/claude-code/scripts/lib/config.py \ + memind-integrations/claude-code/settings.json \ + memind-integrations/claude-code/tests/test_identity.py \ + memind-integrations/claude-code/tests/test_manifest.py \ + memind-integrations/claude-code/tests/test_config.py \ + memind-integrations/codex/scripts/lib/identity.py \ + memind-integrations/codex/scripts/lib/config.py \ + memind-integrations/codex/settings.json \ + memind-integrations/codex/tests/test_identity.py \ + memind-integrations/codex/tests/test_manifest.py \ + memind-integrations/codex/tests/test_config.py +git commit -m "fix: make coding agent identities project scoped" +``` + +### Task 2: Normalize Session, Turn, Timeline, And Event Attribution + +**Files:** +- Modify: `memind-integrations/claude-code/scripts/lib/agent_timeline.py` +- Modify: `memind-integrations/claude-code/scripts/retrieve.py` +- Modify: `memind-integrations/claude-code/scripts/pre_tool_use.py` +- Modify: `memind-integrations/claude-code/scripts/post_tool_use.py` +- Modify: `memind-integrations/claude-code/scripts/ingest.py` +- Modify: `memind-integrations/claude-code/tests/test_agent_timeline.py` +- Modify: `memind-integrations/claude-code/tests/test_hooks.py` +- Modify: `memind-integrations/codex/scripts/lib/agent_timeline.py` +- Modify: `memind-integrations/codex/scripts/retrieve.py` +- Modify: `memind-integrations/codex/scripts/pre_tool_use.py` +- Modify: `memind-integrations/codex/scripts/post_tool_use.py` +- Modify: `memind-integrations/codex/scripts/ingest.py` +- Modify: `memind-integrations/codex/tests/test_agent_timeline.py` +- Modify: `memind-integrations/codex/tests/test_hooks.py` + +- [ ] **Step 1: Add failing tests for event metadata** + +In both integration `test_agent_timeline.py` files, extend existing user prompt/tool/stop tests to assert metadata contains: + +```python +self.assertEqual(event["metadata"]["sessionId"], "s") +self.assertEqual(event["metadata"]["sourceClient"], "claude-code") # "codex" in Codex tests +self.assertEqual(event["metadata"]["turnId"], "s-turn-1") +self.assertEqual(event["metadata"]["turnSeq"], 1) +``` + +For `build_timeline_payload()`, add assertions: + +```python +self.assertEqual(payload["metadata"]["sessionId"], "s") +self.assertEqual(payload["metadata"]["sourceClient"], "claude-code") # "codex" in Codex tests +self.assertEqual(payload["metadata"]["turnId"], "s-turn-1") +self.assertEqual(payload["metadata"]["turnSeq"], 1) +self.assertEqual(payload["metadata"]["eventIds"], ["e1", "e2"]) +self.assertEqual(payload["timelineId"], "s-turn-1-timeline") +``` + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_agent_timeline.py \ + memind-integrations/codex/tests/test_agent_timeline.py +``` + +Expected: tests fail because session/source metadata is not consistently present on every event and timeline metadata. + +- [ ] **Step 2: Centralize base event metadata** + +In both `agent_timeline.py` files, update `_base_event()` so all events created through it include: + +```python +metadata = { + "hookEventName": hook_input.get("hook_event_name"), + "sessionId": session_id, + "sourceClient": source_client, +} +``` + +Keep `turnId` and `turnSeq` only when passed. + +- [ ] **Step 3: Add session/source metadata to tool events** + +In both `normalize_hook_event()` implementations, initialize metadata with: + +```python +metadata = { + "hookEventName": hook_input.get("hook_event_name"), + "sessionId": session_id, + "sourceClient": source_client, +} +``` + +Then merge normalization metadata, turn metadata, and redaction metadata as today. + +- [ ] **Step 4: Add timeline metadata** + +In both `build_timeline_payload()` implementations, include: + +```python +"metadata": { + "userId": identity.get("userId"), + "agentId": identity.get("agentId"), + "sessionId": session_id, + "sourceClient": source_client, + "eventIds": [event["eventId"] for event in events if event.get("eventId")], +} +``` + +Preserve existing `turnId`, `turnSeq`, and `project` behavior. + +- [ ] **Step 5: Run tests** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_agent_timeline.py \ + memind-integrations/claude-code/tests/test_hooks.py \ + memind-integrations/codex/tests/test_agent_timeline.py \ + memind-integrations/codex/tests/test_hooks.py +``` + +Expected: all selected tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add \ + memind-integrations/claude-code/scripts/lib/agent_timeline.py \ + memind-integrations/claude-code/scripts/retrieve.py \ + memind-integrations/claude-code/scripts/pre_tool_use.py \ + memind-integrations/claude-code/scripts/post_tool_use.py \ + memind-integrations/claude-code/scripts/ingest.py \ + memind-integrations/claude-code/tests/test_agent_timeline.py \ + memind-integrations/claude-code/tests/test_hooks.py \ + memind-integrations/codex/scripts/lib/agent_timeline.py \ + memind-integrations/codex/scripts/retrieve.py \ + memind-integrations/codex/scripts/pre_tool_use.py \ + memind-integrations/codex/scripts/post_tool_use.py \ + memind-integrations/codex/scripts/ingest.py \ + memind-integrations/codex/tests/test_agent_timeline.py \ + memind-integrations/codex/tests/test_hooks.py +git commit -m "fix: normalize agent timeline attribution metadata" +``` + +### Task 3: Expand Normalized Agent Event Kinds + +**Files:** +- Modify: `memind-integrations/claude-code/scripts/lib/agent_timeline.py` +- Modify: `memind-integrations/claude-code/tests/test_agent_timeline.py` +- Modify: `memind-integrations/codex/scripts/lib/agent_timeline.py` +- Modify: `memind-integrations/codex/tests/test_agent_timeline.py` +- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEventKind.java` +- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java` + +- [ ] **Step 1: Add failing Python normalization tests** + +In Claude Code `test_agent_timeline.py`, add tests for: + +```python +def test_normalizes_notification_event(self): + event = normalize_notification_event( + { + "hook_event_name": "Notification", + "session_id": "s", + "message": "Claude needs permission to run Bash", + "timestamp": "2026-05-24T10:00:00Z", + }, + seq=1, + turn_id="s-turn-1", + turn_seq=1, + ) + self.assertEqual(event["kind"], "notification") + self.assertEqual(event["text"], "Claude needs permission to run Bash") + self.assertEqual(event["status"], "success") + + +def test_normalizes_subagent_stop_event(self): + event = normalize_subagent_stop_event( + { + "hook_event_name": "SubagentStop", + "session_id": "s", + "subagent_type": "explorer", + "message": "Found failing resolver test", + "timestamp": "2026-05-24T10:01:00Z", + }, + seq=2, + turn_id="s-turn-1", + turn_seq=1, + ) + self.assertEqual(event["kind"], "subagent_stop") + self.assertEqual(event["operation"], "explorer") + self.assertIn("Found failing resolver test", event["text"]) +``` + +In both Claude Code and Codex `test_agent_timeline.py`, add tests for: + +```python +def test_normalizes_compact_boundary_event(self): + event = normalize_compact_boundary_event( + {"hook_event_name": "PreCompact", "session_id": "s", "timestamp": "2026-05-24T10:02:00Z"}, + seq=3, + turn_id="s-turn-1", + turn_seq=1, + ) + self.assertEqual(event["kind"], "compact_boundary") + self.assertEqual(event["status"], "success") + + +def test_normalizes_session_end_event(self): + event = normalize_session_end_event( + {"hook_event_name": "SessionEnd", "session_id": "s", "timestamp": "2026-05-24T10:03:00Z"}, + seq=4, + turn_id="s-turn-1", + turn_seq=1, + ) + self.assertEqual(event["kind"], "session_end") + self.assertEqual(event["status"], "success") +``` + +Codex may not use compact/session-end hooks yet, but shared parsing should accept those event kinds if rawdata arrives from another adapter. + +- [ ] **Step 2: Implement Python normalization helpers** + +In Claude Code `agent_timeline.py`, add: + +```python +def normalize_notification_event(hook_input, seq, turn_id=None, turn_seq=None): + text, redaction_kinds = redact_text( + hook_input.get("message") + or hook_input.get("notification") + or hook_input.get("text") + or "" + ) + event = _base_event(hook_input, seq, "notification", turn_id, turn_seq, text) + event["text"] = text + event["status"] = "success" + metadata = dict(event["metadata"]) + if _looks_blocking_notification(text): + metadata["notificationKind"] = "blocked" + metadata["failureSignal"] = text + else: + metadata["notificationKind"] = "info" + if redaction_kinds: + metadata["redacted"] = True + metadata["redactionKinds"] = sorted(set(redaction_kinds)) + event["metadata"] = metadata + return {key: value for key, value in event.items() if value is not None and value != ""} +``` + +Add the small helper: + +```python +def _looks_blocking_notification(text): + lowered = (text or "").lower() + return any(token in lowered for token in ["permission", "blocked", "denied", "failed", "error"]) +``` + +Add: + +```python +def normalize_subagent_stop_event(hook_input, seq, turn_id=None, turn_seq=None): + subagent_type = hook_input.get("subagent_type") or hook_input.get("subagentType") or hook_input.get("type") + text, redaction_kinds = redact_text( + hook_input.get("message") + or hook_input.get("summary") + or hook_input.get("result") + or "" + ) + event = _base_event(hook_input, seq, "subagent_stop", turn_id, turn_seq, text) + event["text"] = text + event["operation"] = subagent_type + event["status"] = "success" + metadata = dict(event["metadata"]) + if subagent_type: + metadata["subagentType"] = subagent_type + if redaction_kinds: + metadata["redacted"] = True + metadata["redactionKinds"] = sorted(set(redaction_kinds)) + event["metadata"] = metadata + return {key: value for key, value in event.items() if value is not None and value != ""} +``` + +Add: + +```python +def normalize_compact_boundary_event(hook_input, seq, turn_id=None, turn_seq=None): + event = _base_event(hook_input, seq, "compact_boundary", turn_id, turn_seq, "compact") + event["status"] = "success" + event["operation"] = hook_input.get("trigger") or hook_input.get("compact_reason") or "compact" + return {key: value for key, value in event.items() if value is not None and value != ""} + + +def normalize_session_end_event(hook_input, seq, turn_id=None, turn_seq=None): + event = _base_event(hook_input, seq, "session_end", turn_id, turn_seq, "session_end") + event["status"] = "success" + event["operation"] = hook_input.get("reason") or hook_input.get("session_end_reason") or "session_end" + return {key: value for key, value in event.items() if value is not None and value != ""} +``` + +In Codex `agent_timeline.py`, add only the helpers that are exercised by Codex tests in this phase. Do not add Codex hook scripts, manifest entries, or README claims for `Notification`, `SubagentStop`, `PreCompact`, or `SessionEnd` unless Codex support is explicitly added and tested in the Codex adapter. If compact/session-end helper functions are added for parser compatibility, keep them private to normalization tests and document that they are accepted raw event shapes, not registered Codex hooks. + +- [ ] **Step 3: Add failing Java enum parsing test** + +In `AgentTimelineContentTest.java`, add a test that parses/uses these wire values: + +```java +assertThat(AgentEventKind.fromWireValue("notification")).isEqualTo(AgentEventKind.NOTIFICATION); +assertThat(AgentEventKind.fromWireValue("subagent_stop")).isEqualTo(AgentEventKind.SUBAGENT_STOP); +assertThat(AgentEventKind.fromWireValue("compact_boundary")).isEqualTo(AgentEventKind.COMPACT_BOUNDARY); +assertThat(AgentEventKind.fromWireValue("synthetic_boundary")).isEqualTo(AgentEventKind.SYNTHETIC_BOUNDARY); +``` + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent test \ + -Dtest=AgentTimelineContentTest +``` + +Expected: test fails because enum constants are missing. + +- [ ] **Step 4: Add Java event kinds** + +In `AgentEventKind.java`, add: + +```java +TOOL_START, +TOOL_FAILURE, +SUBAGENT_START, +SUBAGENT_STOP, +NOTIFICATION, +COMPACT_BOUNDARY, +SYNTHETIC_BOUNDARY, +``` + +Keep existing constants. `TASK_COMPLETED` remains a general rawdata-agent kind even if Claude Code/Codex do not register a `TaskCompleted` hook in this phase. + +- [ ] **Step 5: Run selected tests** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_agent_timeline.py \ + memind-integrations/codex/tests/test_agent_timeline.py +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent test \ + -Dtest=AgentTimelineContentTest +``` + +Expected: all selected tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add \ + memind-integrations/claude-code/scripts/lib/agent_timeline.py \ + memind-integrations/claude-code/tests/test_agent_timeline.py \ + memind-integrations/codex/scripts/lib/agent_timeline.py \ + memind-integrations/codex/tests/test_agent_timeline.py \ + memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEventKind.java \ + memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java +git commit -m "feat: expand normalized agent event kinds" +``` + +### Task 4: Add Claude Code Notification And Subagent Hook Capture + +**Files:** +- Create: `memind-integrations/claude-code/scripts/notification.py` +- Create: `memind-integrations/claude-code/scripts/subagent_stop.py` +- Modify: `memind-integrations/claude-code/hooks/hooks.json` +- Modify: `memind-integrations/claude-code/tests/test_hooks.py` +- Modify: `memind-integrations/claude-code/tests/test_manifest.py` + +- [ ] **Step 1: Add failing manifest tests** + +In `memind-integrations/claude-code/tests/test_manifest.py`, add `Notification` and `SubagentStop` to the expected hook list: + +```python +for event in [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "Notification", + "SubagentStop", + "PreCompact", + "Stop", + "SessionEnd", +]: + self.assertIn(event, hooks) +``` + +Assert both new hook commands are async and fail-open sized: + +```python +self.assertTrue(hooks["Notification"][0]["hooks"][0]["async"]) +self.assertTrue(hooks["SubagentStop"][0]["hooks"][0]["async"]) +self.assertEqual(hooks["Notification"][0]["hooks"][0]["timeout"], 5) +self.assertEqual(hooks["SubagentStop"][0]["hooks"][0]["timeout"], 5) +``` + +- [ ] **Step 2: Add failing hook execution tests** + +In `memind-integrations/claude-code/tests/test_hooks.py`, add: + +```python +def test_notification_buffers_event(self): + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + env = { + "CLAUDE_PLUGIN_ROOT": str(ROOT), + "PYTHONPATH": str(ROOT), + "MEMIND_CLAUDE_STATE_ROOT": str(state_dir), + } + output = self.run_hook( + "notification.py", + { + "hook_event_name": "Notification", + "cwd": tmp, + "session_id": "s1", + "message": "Permission required for Bash", + }, + env=env, + ) + self.assertEqual(output, {"continue": True}) + event = json.loads(next(state_dir.glob("*.json")).read_text())["agentEvents"][0] + self.assertEqual(event["kind"], "notification") + self.assertEqual(event["metadata"]["notificationKind"], "blocked") + + +def test_subagent_stop_buffers_event(self): + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + env = { + "CLAUDE_PLUGIN_ROOT": str(ROOT), + "PYTHONPATH": str(ROOT), + "MEMIND_CLAUDE_STATE_ROOT": str(state_dir), + } + output = self.run_hook( + "subagent_stop.py", + { + "hook_event_name": "SubagentStop", + "cwd": tmp, + "session_id": "s1", + "subagent_type": "explorer", + "message": "Found failing resolver test", + }, + env=env, + ) + self.assertEqual(output, {"continue": True}) + event = json.loads(next(state_dir.glob("*.json")).read_text())["agentEvents"][0] + self.assertEqual(event["kind"], "subagent_stop") + self.assertEqual(event["operation"], "explorer") +``` + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_manifest.py \ + memind-integrations/claude-code/tests/test_hooks.py +``` + +Expected: tests fail because scripts and hook entries do not exist. + +- [ ] **Step 3: Implement `notification.py`** + +Create `memind-integrations/claude-code/scripts/notification.py` using the same fail-open pattern as `pre_tool_use.py`: + +```python +#!/usr/bin/env python3 +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from ingest import state_root +from lib.agent_timeline import normalize_notification_event +from lib.config import load_config +from lib.logging_utils import debug_log +from lib.state import SessionStateStore + + +def main(): + try: + hook_input = json.loads(sys.stdin.read() or "{}") + config = load_config() + session_id = hook_input.get("session_id") or "unknown-session" + hook_input["source_client"] = config.get("sourceClient") or "claude-code" + with SessionStateStore(state_root()).locked(session_id) as state: + turn_id, turn_seq = state.ensure_agent_turn(session_id) + seq = state.next_agent_seq() + state.append_agent_event( + normalize_notification_event( + hook_input, seq, turn_id=turn_id, turn_seq=turn_seq + ) + ) + except Exception as exc: + try: + debug_log(load_config(), "notification_failed", {"error": str(exc)}) + except Exception: + pass + print(json.dumps({"continue": True})) + + +if __name__ == "__main__": + main() +``` + +Add the standard Apache license header before imports to match repository style. + +- [ ] **Step 4: Implement `subagent_stop.py`** + +Create `memind-integrations/claude-code/scripts/subagent_stop.py` with the same structure, calling `normalize_subagent_stop_event()` and logging `"subagent_stop_failed"`. + +- [ ] **Step 5: Register hooks** + +In `memind-integrations/claude-code/hooks/hooks.json`, add: + +```json +"Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/notification.py\"", + "timeout": 5, + "async": true + } + ] + } +], +"SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/subagent_stop.py\"", + "timeout": 5, + "async": true + } + ] + } +] +``` + +- [ ] **Step 6: Run tests** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_manifest.py \ + memind-integrations/claude-code/tests/test_hooks.py +``` + +Expected: all selected tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add \ + memind-integrations/claude-code/scripts/notification.py \ + memind-integrations/claude-code/scripts/subagent_stop.py \ + memind-integrations/claude-code/hooks/hooks.json \ + memind-integrations/claude-code/tests/test_hooks.py \ + memind-integrations/claude-code/tests/test_manifest.py +git commit -m "feat: capture claude code notification and subagent hooks" +``` + +### Task 5: Preserve Codex Hook Boundaries Without Simulating Unsupported Hooks + +**Files:** +- Modify: `memind-integrations/codex/hooks/hooks.json` +- Modify: `memind-integrations/codex/scripts/install_codex_hooks.py` +- Modify: `memind-integrations/codex/tests/test_manifest.py` +- Modify: `memind-integrations/codex/tests/test_installer.py` +- Modify: `memind-integrations/codex/README.md` + +- [ ] **Step 1: Add explicit Codex supported-hook test** + +In `memind-integrations/codex/tests/test_manifest.py`, keep the supported set strict: + +```python +self.assertEqual( + set(hooks), + {"SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"}, +) +self.assertNotIn("PreCompact", hooks) +self.assertNotIn("SessionEnd", hooks) +self.assertNotIn("Notification", hooks) +self.assertNotIn("SubagentStop", hooks) +``` + +Run: + +```bash +python3 -m unittest memind-integrations/codex/tests/test_manifest.py +``` + +Expected: pass against current manifest. This test protects against accidental Claude Code hook leakage. + +- [ ] **Step 2: Add installer regression assertion** + +In `memind-integrations/codex/tests/test_installer.py`, add assertions after install that the installed hooks contain exactly the supported set plus any unrelated pre-existing hooks: + +```python +memind_events = { + event + for event, groups in hooks["hooks"].items() + for group in groups + for hook in group.get("hooks", []) + if "memind-integrations/codex" in hook.get("command", "") or "/memind/codex/" in hook.get("command", "") +} +self.assertEqual( + memind_events, + {"SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"}, +) +``` + +- [ ] **Step 3: Update Codex README** + +In `memind-integrations/codex/README.md`, add a short note under Hook Events: + +```markdown +Codex currently registers only the hook events listed above. Memind does not simulate Claude Code-only lifecycle +events such as `PreCompact`, `SessionEnd`, `Notification`, or `SubagentStop` in the Codex adapter. If Codex adds +native support for additional lifecycle events, they should be added as explicit hooks with tests. +``` + +- [ ] **Step 4: Run Codex tests** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/codex/tests/test_manifest.py \ + memind-integrations/codex/tests/test_installer.py +``` + +Expected: all selected tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add \ + memind-integrations/codex/hooks/hooks.json \ + memind-integrations/codex/scripts/install_codex_hooks.py \ + memind-integrations/codex/tests/test_manifest.py \ + memind-integrations/codex/tests/test_installer.py \ + memind-integrations/codex/README.md +git commit -m "test: keep codex hook support explicit" +``` + +### Task 6: Make Durable Journal Flush Boundaries Explicit + +**Files:** +- Modify: `memind-integrations/claude-code/scripts/lib/state.py` +- Modify: `memind-integrations/claude-code/scripts/ingest.py` +- Modify: `memind-integrations/claude-code/scripts/pre_compact.py` +- Modify: `memind-integrations/claude-code/scripts/session_end.py` +- Modify: `memind-integrations/claude-code/tests/test_state.py` +- Modify: `memind-integrations/claude-code/tests/test_hooks.py` +- Modify: `memind-integrations/codex/scripts/lib/state.py` +- Modify: `memind-integrations/codex/scripts/ingest.py` +- Modify: `memind-integrations/codex/tests/test_state.py` +- Modify: `memind-integrations/codex/tests/test_hooks.py` + +- [ ] **Step 1: Add state cleanup tests** + +In both `test_state.py` files, add tests for empty state cleanup behavior through a new `is_empty()` helper: + +```python +def test_state_reports_empty_after_all_events_are_cleared_and_turn_closed(self): + with tempfile.TemporaryDirectory() as tmp: + store = SessionStateStore(Path(tmp)) + with store.locked("session-1") as state: + turn_id, _turn_seq = state.start_agent_turn("session-1") + state.append_agent_event({"eventId": "e1", "seq": 1}) + state.clear_agent_events(["e1"]) + state.close_agent_turn(turn_id) + self.assertTrue(state.is_empty()) +``` + +For Codex, use the same test shape but name the local variable `session_key = "session-1"` before calling `store.locked(session_key)`. The expected behavior is identical because Codex's state store already converts hook payloads to a stable session key before opening the store. + +- [ ] **Step 2: Add boundary preservation test** + +In both `test_state.py` files, update or add a soft cap test so important boundaries survive truncation: + +```python +def test_agent_event_buffer_soft_cap_preserves_current_turn_boundaries(self): + with tempfile.TemporaryDirectory() as tmp: + store = SessionStateStore(Path(tmp)) + with store.locked("session-1") as state: + state.append_agent_event({"eventId": "prompt", "seq": 1, "kind": "user_prompt"}) + for index in range(600): + state.append_agent_event({"eventId": f"e{index}", "seq": index + 2, "kind": "tool_result"}) + state.append_agent_event({"eventId": "stop", "seq": 700, "kind": "stop"}) + with store.locked("session-1") as state: + events = state.agent_events() + self.assertLessEqual(len(events), 500) + self.assertEqual(events[-1]["eventId"], "stop") + self.assertTrue(state.data["agentEventsTruncated"]) + self.assertIn("agentEventsDropped", state.data) +``` + +This test does not require preserving the oldest `USER_PROMPT` forever if the session has exceeded the cap before flush. It does require explicit truncation metadata and preserving the newest terminal boundary. + +- [ ] **Step 3: Implement state helpers** + +In both `state.py` files, add: + +```python +def is_empty(self): + return ( + not self.data.get("agentEvents") + and not self.data.get("currentAgentTurnId") + and not self.data.get("currentAgentTurnSeq") + ) +``` + +When soft cap truncates, increment a counter: + +```python +dropped = len(events) - MAX_AGENT_EVENTS +events = events[-MAX_AGENT_EVENTS:] +self.data["agentEventsTruncated"] = True +self.data["agentEventsDropped"] = int(self.data.get("agentEventsDropped", 0)) + dropped +``` + +- [ ] **Step 4: Add explicit Claude Code boundary event tests** + +In Claude Code `test_hooks.py`, add: + +```python +def test_pre_compact_appends_compact_boundary_before_flush(self): + sys.path.insert(0, str(ROOT / "scripts")) + import ingest + from scripts.lib.state import SessionStateStore + + config = { + "memindApiUrl": "http://127.0.0.1:8366", + "memindApiToken": None, + "autoIngestAgentTimeline": True, + "ingestRetrySpool": False, + "sourceClient": "claude-code", + "agentId": "claude-code", + "userId": "u", + } + with tempfile.TemporaryDirectory() as tmp: + state_root = Path(tmp) / "state" + with SessionStateStore(state_root).locked("s1") as state: + state.append_agent_event( + { + "eventId": "e1", + "seq": 1, + "kind": "user_prompt", + "text": "Continue before compaction", + "metadata": {"turnId": "s1-turn-1", "turnSeq": 1}, + } + ) + with mock.patch.object(ingest, "state_root", return_value=state_root): + with mock.patch.object(ingest, "retry_root", return_value=Path(tmp) / "retry"): + with mock.patch.object(ingest, "MemindClient") as client_cls: + client = client_cls.return_value + client.extract = mock.AsyncMock(return_value=types.SimpleNamespace(status="SUCCESS")) + result = ingest.ingest_messages( + config, + { + "hook_event_name": "PreCompact", + "session_id": "s1", + "cwd": tmp, + "timestamp": "2026-05-24T10:04:00Z", + }, + ) + self.assertEqual(result["agentEventsSubmitted"], 2) + raw_content = client.extract.await_args.args[2] + self.assertEqual(raw_content["events"][-1]["kind"], "compact_boundary") +``` + +And: + +```python +def test_session_end_appends_session_end_before_flush(self): + sys.path.insert(0, str(ROOT / "scripts")) + import ingest + from scripts.lib.state import SessionStateStore + + config = { + "memindApiUrl": "http://127.0.0.1:8366", + "memindApiToken": None, + "autoIngestAgentTimeline": True, + "ingestRetrySpool": False, + "sourceClient": "claude-code", + "agentId": "claude-code", + "userId": "u", + } + with tempfile.TemporaryDirectory() as tmp: + state_root = Path(tmp) / "state" + with SessionStateStore(state_root).locked("s1") as state: + state.append_agent_event( + { + "eventId": "e1", + "seq": 1, + "kind": "command", + "command": "npm test payment", + "metadata": {"turnId": "s1-turn-1", "turnSeq": 1}, + } + ) + with mock.patch.object(ingest, "state_root", return_value=state_root): + with mock.patch.object(ingest, "retry_root", return_value=Path(tmp) / "retry"): + with mock.patch.object(ingest, "MemindClient") as client_cls: + client = client_cls.return_value + client.extract = mock.AsyncMock(return_value=types.SimpleNamespace(status="SUCCESS")) + result = ingest.ingest_messages( + config, + { + "hook_event_name": "SessionEnd", + "session_id": "s1", + "cwd": tmp, + "timestamp": "2026-05-24T10:05:00Z", + }, + ) + self.assertEqual(result["agentEventsSubmitted"], 2) + raw_content = client.extract.await_args.args[2] + self.assertEqual(raw_content["events"][-1]["kind"], "session_end") +``` + +These tests intentionally call `ingest.ingest_messages()` directly instead of shelling out to `pre_compact.py` and `session_end.py`, because the behavior under test is the shared flush path and boundary append logic. + +- [ ] **Step 5: Refactor Claude Code flush entrypoint** + +In `ingest.py`, add a helper: + +```python +def _append_boundary_event(state, session_id, hook_input): + hook_name = hook_input.get("hook_event_name") or "" + if hook_name == "Stop": + return _append_stop_events(state, session_id, hook_input) + if hook_name == "PreCompact": + turn_id, turn_seq = state.ensure_agent_turn(session_id) + seq = state.next_agent_seq() + state.append_agent_event( + normalize_compact_boundary_event(hook_input, seq, turn_id=turn_id, turn_seq=turn_seq) + ) + return turn_id + if hook_name == "SessionEnd": + turn_id, turn_seq = state.ensure_agent_turn(session_id) + seq = state.next_agent_seq() + state.append_agent_event( + normalize_session_end_event(hook_input, seq, turn_id=turn_id, turn_seq=turn_seq) + ) + return turn_id + return None +``` + +Use this helper where `_append_stop_events()` is called today. + +After successful flush and `state.close_agent_turn(submitted_turn_id)`, keep the empty state file behavior conservative: clearing events is enough for this phase. Do not add `SessionStateStore.delete_if_empty(...)` in this phase. The new `is_empty()` helper is a testable state invariant and a future cleanup hook, not a requirement to delete journal files now. Never delete a state file that still contains unflushed events. + +- [ ] **Step 6: Keep Codex Stop boundary behavior** + +In Codex `ingest.py`, preserve the existing Stop-only behavior. Add assertions to existing Codex Stop tests that the final event is `stop` and the turn closes after successful flush. + +- [ ] **Step 7: Run tests** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_state.py \ + memind-integrations/claude-code/tests/test_hooks.py \ + memind-integrations/codex/tests/test_state.py \ + memind-integrations/codex/tests/test_hooks.py +``` + +Expected: all selected tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add \ + memind-integrations/claude-code/scripts/lib/state.py \ + memind-integrations/claude-code/scripts/ingest.py \ + memind-integrations/claude-code/scripts/pre_compact.py \ + memind-integrations/claude-code/scripts/session_end.py \ + memind-integrations/claude-code/tests/test_state.py \ + memind-integrations/claude-code/tests/test_hooks.py \ + memind-integrations/codex/scripts/lib/state.py \ + memind-integrations/codex/scripts/ingest.py \ + memind-integrations/codex/tests/test_state.py \ + memind-integrations/codex/tests/test_hooks.py +git commit -m "fix: make agent journal flush boundaries explicit" +``` + +### Task 7: Update rawdata-agent Episode Assembly For Richer Events + +**Files:** +- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java` +- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java` +- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java` +- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java` + +- [ ] **Step 1: Add failing episode boundary test** + +In `AgentEpisodeAssemblerTest.java`, add a test with: + +- `USER_PROMPT` seq 1. +- `FILE_READ` seq 2. +- `SUBAGENT_STOP` seq 3. +- `STOP` seq 4. +- `USER_PROMPT` seq 5. +- `COMMAND` seq 6. +- `COMPACT_BOUNDARY` seq 7. + +Assert: + +```java +assertThat(episodes).hasSize(2); +assertThat(episodes.get(0).eventIds()).containsExactly("e1", "e2", "e3", "e4"); +assertThat(episodes.get(1).eventIds()).containsExactly("e5", "e6", "e7"); +assertThat(episodes.get(0).phase()).isEqualTo("full"); +assertThat(episodes.get(1).phase()).isEqualTo("full"); +``` + +- [ ] **Step 2: Add failing phase classification test** + +In `AgentEpisodeAssemblerTest.java`, add assertions that: + +- `SUBAGENT_STOP` is not an implementation event by itself. +- `NOTIFICATION` with failed/cancelled status contributes to failure signals. +- `COMPACT_BOUNDARY`, `SESSION_END`, `SYNTHETIC_BOUNDARY`, and `STOP` are terminal events. + +- [ ] **Step 3: Implement terminal boundaries** + +In `AgentEpisodeAssembler.isTerminal()`, include: + +```java +return event.kind() == AgentEventKind.STOP + || event.kind() == AgentEventKind.SESSION_END + || event.kind() == AgentEventKind.COMPACT_BOUNDARY + || event.kind() == AgentEventKind.SYNTHETIC_BOUNDARY + || event.kind() == AgentEventKind.TASK_COMPLETED; +``` + +- [ ] **Step 4: Update phase classification** + +In `phase(AgentEvent event)`, treat: + +- `FILE_EDIT` as `implementation`. +- successful `COMMAND` and `TEST_RESULT` as `validation`. +- terminal events and `ASSISTANT_MESSAGE` as `handoff`. +- `SUBAGENT_STOP`, `NOTIFICATION`, `FILE_READ`, `TOOL_RESULT`, and unknown tool evidence as `investigation` unless stronger local evidence indicates otherwise. + +- [ ] **Step 5: Ensure formatter does not drop new events** + +In `AgentTimelineContent` formatting tests, add a sample event for `notification`, `subagent_stop`, and `compact_boundary`. Assert formatted text contains the kind and meaningful text/operation. + +- [ ] **Step 6: Run tests** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent test \ + -Dtest=AgentEpisodeAssemblerTest,AgentTimelineContentTest +``` + +Expected: selected tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add \ + memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java \ + memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java \ + memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java \ + memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java +git commit -m "feat: segment agent episodes across lifecycle boundaries" +``` + +### Task 8: Improve Deterministic Tool And Resolution Evidence + +**Files:** +- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java` +- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java` +- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java` +- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyLlmTest.java` + +- [ ] **Step 1: Add failing deterministic resolution test** + +In `AgentItemExtractionStrategyTest.java`, add a segment metadata fixture with: + +- `failureSignals = ["payment rounding mismatch"]` +- `commandEvents` containing failed `npm test payment` at seq 3 and successful `npm test payment` at seq 8. +- `fileEvents` for `src/payment/calc.ts` between seq 3 and seq 8. +- `eventIds` containing all evidence IDs. + +Assert extracted deterministic `resolution` metadata: + +```java +assertThat(resolution.metadata().get("validatedBy")).isEqualTo("npm test payment"); +assertThat((List) resolution.metadata().get("evidenceEventIds")) + .containsExactly("failed-test", "edit-calc", "passed-test"); +``` + +This test makes the "later successful validation" rule precise: later means `candidate.seq > failed.seq`, successful means `status == success`, and matching means `sameCommandFamily(failed.command, candidate.command)`. + +- [ ] **Step 2: Add weak notification test** + +In `AgentItemExtractionStrategyTest.java`, add a segment metadata fixture with: + +```java +Map.of( + "segmentType", "agent_episode", + "episodeId", "episode-notification", + "eventIds", List.of("notice-1"), + "files", List.of(), + "commands", List.of(), + "toolNames", List.of(), + "failureSignals", List.of(), + "outcome", "unknown") +``` + +The segment text should mention a harmless notification, such as `Claude is waiting for input.`. Assert: + +```java +assertThat(strategy.extract(List.of(segment), List.of(), config).block()).isEmpty(); +``` + +If the test uses `AgentMemoryItemFactory` directly, assert `deterministicEntries(segment)` is empty. This prevents low-value notifications from becoming memory items by themselves. + +- [ ] **Step 3: Add subagent evidence test** + +In `AgentItemExtractionStrategyLlmTest.java`, add a segment whose metadata includes: + +```java +Map.of( + "segmentType", "agent_episode", + "episodeId", "episode-subagent", + "eventIds", List.of("prompt-1", "subagent-1", "stop-1"), + "files", List.of("src/payment/calc.ts"), + "commands", List.of("npm test payment"), + "toolNames", List.of("Task"), + "failureSignals", List.of(), + "outcome", "success") +``` + +Mock the structured chat client to return an extracted item: + +```java +new MemoryItemExtractionResponse.ExtractedItem( + "When payment test failures are unclear, ask an explorer subagent to inspect the failing resolver before editing calc.ts.", + 0.86f, + null, + null, + List.of("playbooks"), + Map.of("evidenceEventIds", List.of("subagent-1")), + "playbook") +``` + +Assert the item is accepted and its metadata keeps `evidenceEventIds = ["subagent-1"]`. Add a companion response with `evidenceEventIds = ["outside-event"]` and assert it is rejected. + +- [ ] **Step 4: Tighten deterministic resolution logic** + +In `AgentMemoryItemFactory`, keep the existing validation scan but ensure: + +- failed command event must have `failed() == true`; +- validation candidate must have `candidate.seq() > failed.seq()`; +- validation candidate must have `success() == true`; +- `sameCommandFamily(failed.command(), candidate.command())` must be true; +- file evidence is included only when `failed.seq < file.seq < candidate.seq`; +- all evidence IDs are non-blank and de-duplicated in event order. + +- [ ] **Step 5: Validate LLM evidence IDs** + +In `AgentItemExtractionStrategy.isValidItem()`, preserve the existing evidence validation and add a targeted test that rejects an LLM item with: + +```json +{"metadata": {"evidenceEventIds": ["outside-event"]}} +``` + +when `outside-event` is not in the segment metadata `eventIds`. + +- [ ] **Step 6: Run tests** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent test \ + -Dtest=AgentItemExtractionStrategyTest,AgentItemExtractionStrategyLlmTest +``` + +Expected: selected tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add \ + memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java \ + memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java \ + memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java \ + memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyLlmTest.java +git commit -m "fix: strengthen agent item evidence validation" +``` + +### Task 9: Update Captions For Lifecycle-Aware Episodes + +**Files:** +- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGenerator.java` +- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGeneratorTest.java` + +- [ ] **Step 1: Add failing caption tests** + +In `AgentCaptionGeneratorTest.java`, add tests for: + +- An episode with `goal = "Fix payment tests"`, `SUBAGENT_STOP`, file edit, failed test, passed test, and stop. +- An episode ending with `COMPACT_BOUNDARY`. + +Assert captions include: + +```java +assertThat(caption.text()).contains("Fix payment tests"); +assertThat(caption.text()).contains("src/payment/calc.ts"); +assertThat(caption.text()).contains("npm test payment"); +``` + +For compact boundary: + +```java +assertThat(caption.text()).contains("compact"); +``` + +- [ ] **Step 2: Update caption logic** + +Keep the caption deterministic and concise: + +- Prefer goal/user prompt. +- Include top files and commands. +- Include outcome. +- Mention compact/session boundary only when it is the terminal reason and useful for later continuation. +- Do not include raw tool output unless it is already present as a concise failure signal. + +- [ ] **Step 3: Run tests** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent test \ + -Dtest=AgentCaptionGeneratorTest +``` + +Expected: selected tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add \ + memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGenerator.java \ + memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGeneratorTest.java +git commit -m "feat: improve lifecycle-aware agent captions" +``` + +### Task 10: Update Documentation For Both Integrations + +**Files:** +- Modify: `memind-integrations/claude-code/README.md` +- Modify: `memind-integrations/codex/README.md` +- Modify: `docs/superpowers/specs/2026-05-24-rawdata-agent-design.md` only if the implementation has made the existing spec materially stale. + +- [ ] **Step 1: Update Claude Code README** + +Document: + +- Timeline-only ingestion. +- Project-scoped `agentId` is always enabled. +- `agentIdMode` no longer exists. +- Session/turn/timeline IDs are metadata, not Memind core project/session entities. +- Supported hooks: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Notification`, `SubagentStop`, `PreCompact`, `Stop`, `SessionEnd`. +- Failed extraction is spooled and retried on `SessionStart`. +- Retrieval Context Compiler is not part of this phase. + +- [ ] **Step 2: Update Codex README** + +Document: + +- Timeline-only ingestion. +- Project-scoped `agentId` is always enabled. +- `agentIdMode` no longer exists. +- Supported hooks: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`. +- Codex does not simulate Claude Code-only lifecycle hooks. +- Failed extraction is spooled and retried on `SessionStart`. +- Retrieval Context Compiler is not part of this phase. + +- [ ] **Step 3: Remove stale config examples** + +In both READMEs, remove examples like: + +```json +"agentIdMode": "project" +``` + +and remove environment examples: + +```bash +export MEMIND_AGENT_ID_MODE=project +``` + +- [ ] **Step 4: Run doc consistency search** + +Run: + +```bash +rg -n "agentIdMode|MEMIND_AGENT_ID_MODE|conversation rawdata|TaskCompleted" \ + memind-integrations/claude-code \ + memind-integrations/codex \ + docs/superpowers/specs/2026-05-24-rawdata-agent-design.md +``` + +Expected: + +- No `agentIdMode` or `MEMIND_AGENT_ID_MODE` remains in Claude Code/Codex integration docs or settings. +- Any `TaskCompleted` reference is either in the rawdata-agent general schema or removed from Claude Code/Codex hook documentation. Do not remove `TASK_COMPLETED` from the Java rawdata-agent model solely because Claude Code and Codex do not register a `TaskCompleted` hook in this phase. +- Conversation rawdata is not described as default Claude Code/Codex ingestion. + +- [ ] **Step 5: Commit** + +```bash +git add \ + memind-integrations/claude-code/README.md \ + memind-integrations/codex/README.md \ + docs/superpowers/specs/2026-05-24-rawdata-agent-design.md +git commit -m "docs: clarify coding agent timeline ingestion" +``` + +If the spec file does not need changes, omit it from `git add`. + +### Task 11: Full Verification + +**Files:** +- No planned source edits. + +- [ ] **Step 1: Run Claude Code integration tests** + +```bash +python3 -m unittest discover -s memind-integrations/claude-code/tests -p "test_*.py" +``` + +Expected: all tests pass. + +- [ ] **Step 2: Run Codex integration tests** + +```bash +python3 -m unittest discover -s memind-integrations/codex/tests -p "test_*.py" +``` + +Expected: all tests pass. + +- [ ] **Step 3: Run rawdata-agent plugin tests** + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent test +``` + +Expected: all tests pass. + +- [ ] **Step 4: Run broader affected Maven tests** + +If the rawdata-agent plugin touched shared core APIs, run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -am test +``` + +Expected: all tests pass. + +- [ ] **Step 5: Search for removed config and unsupported hook drift** + +```bash +rg -n "agentIdMode|MEMIND_AGENT_ID_MODE" memind-integrations/claude-code memind-integrations/codex +``` + +Expected: no output. + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_manifest.py \ + memind-integrations/codex/tests/test_manifest.py +``` + +Expected: manifests match the supported hook sets. + +- [ ] **Step 6: Inspect final diff** + +```bash +git status --short +git diff --stat +git diff --check +``` + +Expected: + +- Only planned files changed. +- No whitespace errors from `git diff --check`. + +- [ ] **Step 7: Final commit if verification required fixes** + +If verification required small fixes: + +```bash +git add docs/superpowers/plans/2026-05-26-agent-hook-journal-pipeline.md +git commit -m "test: verify agent hook journal pipeline" +``` + +Replace the `git add` path with the actual source or test files fixed during verification. If no fixes were needed, do not create an empty commit. + +## Acceptance Criteria + +- Claude Code and Codex no longer expose `agentIdMode`; both always use project-scoped `agentId`. +- Claude Code captures `Notification` and `SubagentStop` as durable timeline events. +- Codex remains explicit about its supported hook set and does not simulate unsupported Claude Code lifecycle events. +- `USER_PROMPT`, tools, assistant message, stop, compact, session end, notification, and subagent evidence can be represented as normalized `agent_event` records. +- Local journal truncation is explicit and does not silently hide data loss. +- Stop/compact/session boundaries flush `agent_timeline` rawdata without reintroducing transcript conversation ingestion. +- `rawdata-agent` can parse, format, segment, caption, and extract from richer event kinds. +- Deterministic resolution extraction uses precise later-success validation semantics. +- LLM-extracted agent memories cannot cite evidence event IDs outside the episode. +- All changed behavior is covered by Python and Java tests. + +## Notes For Implementation + +- Keep the adapter layer boring and deterministic. It should clean and normalize evidence, not decide high-level memory meaning. +- Do not add project/session tables or core concepts to Memind. +- Do not add a pre-extraction LLM gate in this phase. +- Do not remove `rawdata-toolcall`; it remains a separate plugin with a different scope. +- Keep generated hook scripts fail-open. Memory capture must not block the user's coding session if Memind is unavailable. +- Prefer small commits after each task so review can isolate identity, hook capture, journal behavior, rawdata-agent parsing, and docs. diff --git a/memind-integrations/claude-code/README.md b/memind-integrations/claude-code/README.md index c49b5bf6..e98c4d6e 100644 --- a/memind-integrations/claude-code/README.md +++ b/memind-integrations/claude-code/README.md @@ -23,6 +23,7 @@ The integration is intentionally small: - **Ingestion**: `PreToolUse` and `PostToolUse` buffer normalized tool events locally. `Stop`, `PreCompact`, and `SessionEnd` flush buffered events as `rawContent.type = "agent_timeline"` through `AsyncMemindClient.memory.extract(...)`, so Memind can extract user and agent memories from the same agent turn. + `Notification` and `SubagentStop` are buffered as lifecycle evidence when Claude Code emits them. - **Retry**: failed ingestion payloads are spooled under `~/.memind/claude-code/retry/` and replayed on later `SessionStart` hooks. - **Source tagging**: all requests use `sourceClient = "claude-code"` by default, so Memind can distinguish @@ -104,11 +105,14 @@ The installed hooks are: | `UserPromptSubmit` | `scripts/retrieve.py` | 12s | Buffer the user prompt event and retrieve relevant Memind context. | | `PreToolUse` | `scripts/pre_tool_use.py` | 5s | Buffer a redacted tool-start event in local session state. | | `PostToolUse` | `scripts/post_tool_use.py` | 5s | Buffer a redacted tool-result event in local session state. | +| `Notification` | `scripts/notification.py` | 5s | Buffer permission, blocking, and other user-visible lifecycle notifications. | +| `SubagentStop` | `scripts/subagent_stop.py` | 5s | Buffer subagent completion evidence for later playbook and handoff extraction. | | `PreCompact` | `scripts/pre_compact.py` | 30s | Flush buffered `agent_timeline` events before context compaction. | | `Stop` | `scripts/ingest.py` | 15s | Flush buffered `agent_timeline` events after a turn. | | `SessionEnd` | `scripts/session_end.py` | 10s | Flush remaining buffered `agent_timeline` events at session end. | -`Stop`, `PreToolUse`, and `PostToolUse` are configured as async so regular turn completion stays fast. +`Stop`, `PreToolUse`, `PostToolUse`, `Notification`, and `SubagentStop` are configured as async so regular turn +completion stays fast. ## Configuration @@ -122,7 +126,6 @@ User configuration is optional. Save overrides as `~/.memind/claude-code.json`: "memindApiToken": null, "userId": "local__alice", "agentId": "claude-code", - "agentIdMode": "project", "sourceClient": "claude-code", "autoIngestAgentTimeline": true, "retrieveContextTurns": 0 @@ -143,8 +146,7 @@ Settings are loaded in this order: | `memindApiUrl` | `http://127.0.0.1:8366` | Memind server URL. | | `memindApiToken` | `null` | Optional bearer token. | | `userId` | `local__` | Memind user identity. | -| `agentId` | `claude-code` | Base agent identity. | -| `agentIdMode` | `project` | `project` appends a stable project suffix; any other value uses `agentId` as-is. | +| `agentId` | `claude-code` | Base agent identity. A stable project suffix is always appended before calling Memind. | | `sourceClient` | `claude-code` | Source marker stored with Memind data. | | `autoRetrieve` | `true` | Enables prompt-time memory retrieval. | | `autoIngestAgentTimeline` | `true` | Enables user prompt, tool/result, assistant message, and stop event buffering plus `agent_timeline` rawdata flush. | @@ -164,7 +166,6 @@ export MEMIND_API_URL=http://127.0.0.1:8366 export MEMIND_API_TOKEN=... export MEMIND_USER_ID=local__alice export MEMIND_AGENT_ID=claude-code -export MEMIND_AGENT_ID_MODE=project export MEMIND_SOURCE_CLIENT=claude-code export MEMIND_AUTO_INGEST_AGENT_TIMELINE=true export MEMIND_RETRIEVE_CONTEXT_TURNS=0 @@ -186,14 +187,10 @@ By default, Memind stores Claude Code memory under: The project hash is based on the Git remote URL when available, otherwise the local project path. This keeps different repositories separated while allowing memory to survive moving between Claude Code sessions. -To use one shared Claude Code memory across all projects: - -```json -{ - "agentId": "claude-code", - "agentIdMode": "fixed" -} -``` +`agentId` in configuration is the base identity only. The runtime always appends the project suffix before +retrieval or ingestion, so this integration does not provide a global, all-project Claude Code memory mode. +`sessionId`, `agentTurnId`, `timelineId`, and per-event turn metadata are stored only inside raw content and +item metadata; they do not create Memind core project or session entities. ## Retrieval Behavior @@ -227,9 +224,12 @@ Agent memory items are grouped separately when returned by Memind: ## Directives ``` +This phase keeps retrieval formatting intentionally simple. It does not add a new Retrieval Context Compiler; +retrieved memories are still formatted from Memind items and insights by the adapter. + ## Ingestion Behavior -Ingestion is timeline-only for Claude Code. The plugin does not submit transcript conversation rawdata. It buffers +Ingestion is timeline-only for Claude Code. The plugin does not submit transcript conversation-style raw data. It buffers one turn timeline under `~/.memind/claude-code/state/`: the submitted user prompt, tool and command events, the latest assistant message when available from the transcript, and a stop boundary. It flushes the turn through `AsyncMemindClient.memory.extract(...)` as agent timeline rawdata. A typical timeline payload looks like: @@ -287,7 +287,7 @@ latest assistant message when available from the transcript, and a stop boundary ``` Secrets are redacted before events are written to local state. File content capture is disabled by default; the -hook stores normalized tool metadata, commands, paths, statuses, and compact outputs. +hook stores normalized tool metadata, commands, paths, statuses, and bounded outputs. On `SUCCESS`, the covered events are removed from local state. `PARTIAL_SUCCESS` and failures keep the events available and spool the full timeline payload for later `SessionStart` replay. @@ -330,7 +330,7 @@ claude plugin validate memind-integrations/claude-code ### End-to-End Smoke Test -For a deterministic first test, use a fixed Memind identity. If you already have +For a deterministic first test, use a fixed base Memind identity. If you already have `~/.memind/claude-code.json`, merge these fields instead of replacing the file: ```bash @@ -339,7 +339,6 @@ cat > ~/.memind/claude-code.json <<'JSON' { "userId": "local__memind-smoke", "agentId": "claude-code-smoke", - "agentIdMode": "fixed", "sourceClient": "claude-code", "debug": true } @@ -363,16 +362,16 @@ curl -fsSL -X POST http://127.0.0.1:8366/open/v1/memory/retrieve \ -H 'Content-Type: application/json' \ -d '{ "userId": "local__memind-smoke", - "agentId": "claude-code-smoke", + "agentId": "claude-code-smoke__", "query": "blue-lake-42", "strategy": "SIMPLE", "trace": false }' ``` -The response should include matching `items` or `insights` under `data`. If raw conversation data exists but -`items` and `insights` are empty, the plugin has ingested the conversation but Memind has not produced retrievable -memory entries yet. +Replace `` with the stable suffix shown in debug logs or Memind item metadata. The response should +include matching `items` or `insights` under `data`. If agent timelines exist but `items` and `insights` are empty, +the server-side `rawdata-agent` extractor did not produce retrievable memory entries yet. For local debugging, enable logs: @@ -454,7 +453,7 @@ curl -fsSL http://127.0.0.1:8366/open/v1/health ``` - Confirm `autoRetrieve` is `true`. -- Confirm existing memories are stored under the same `userId` and `agentId`. +- Confirm existing memories are stored under the same `userId` and resolved project-scoped `agentId`. - Try setting `retrieveContextTurns` to `1` or `2` if the current prompt is very short. ### Agent timeline events are not ingested @@ -468,7 +467,7 @@ curl -fsSL http://127.0.0.1:8366/open/v1/health The default reliable mode submits agent timelines through `AsyncMemindClient.memory.extract(...)`, so a `SUCCESS` response means extraction finished for that timeline payload. If retrieval still does not surface the expected -memory, confirm the same `userId` and `agentId` are used for ingestion and retrieval, then inspect +memory, confirm the same `userId` and resolved project-scoped `agentId` are used for ingestion and retrieval, then inspect `~/.memind/claude-code.log` with `MEMIND_DEBUG=true`. ## Limitations diff --git a/memind-integrations/claude-code/hooks/hooks.json b/memind-integrations/claude-code/hooks/hooks.json index a9fd326c..995ebe2a 100644 --- a/memind-integrations/claude-code/hooks/hooks.json +++ b/memind-integrations/claude-code/hooks/hooks.json @@ -46,6 +46,30 @@ ] } ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/notification.py\"", + "timeout": 5, + "async": true + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/subagent_stop.py\"", + "timeout": 5, + "async": true + } + ] + } + ], "PreCompact": [ { "hooks": [ diff --git a/memind-integrations/claude-code/scripts/ingest.py b/memind-integrations/claude-code/scripts/ingest.py index 21d87946..43ef7b53 100644 --- a/memind-integrations/claude-code/scripts/ingest.py +++ b/memind-integrations/claude-code/scripts/ingest.py @@ -25,6 +25,8 @@ from lib.agent_timeline import ( build_timeline_payload, normalize_assistant_message_event, + normalize_compact_boundary_event, + normalize_session_end_event, normalize_stop_event, ) from lib.config import load_config @@ -88,6 +90,29 @@ def _append_stop_events(state, session_id, hook_input): return turn_id +def _append_boundary_event(state, session_id, hook_input): + hook_name = hook_input.get("hook_event_name") or "" + if hook_name == "Stop": + return _append_stop_events(state, session_id, hook_input) + if hook_name == "PreCompact": + turn_id, turn_seq = state.ensure_agent_turn(session_id) + seq = state.next_agent_seq() + state.append_agent_event( + normalize_compact_boundary_event( + hook_input, seq, turn_id=turn_id, turn_seq=turn_seq + ) + ) + return turn_id + if hook_name == "SessionEnd": + turn_id, turn_seq = state.ensure_agent_turn(session_id) + seq = state.next_agent_seq() + state.append_agent_event( + normalize_session_end_event(hook_input, seq, turn_id=turn_id, turn_seq=turn_seq) + ) + return turn_id + return None + + async def ingest_messages_async(config, hook_input): identity = resolve_identity(config, hook_input) client = MemindClient(config["memindApiUrl"], config.get("memindApiToken"), timeout=10, max_retries=0) @@ -100,7 +125,7 @@ async def ingest_messages_async(config, hook_input): with store.locked(session_id) as state: if config.get("autoIngestAgentTimeline", True): hook_input["source_client"] = source_client or "claude-code" - submitted_turn_id = _append_stop_events(state, session_id, hook_input) + submitted_turn_id = _append_boundary_event(state, session_id, hook_input) agent_events = state.agent_events() else: agent_events = [] diff --git a/memind-integrations/claude-code/scripts/lib/agent_timeline.py b/memind-integrations/claude-code/scripts/lib/agent_timeline.py index 246e9255..3002be15 100644 --- a/memind-integrations/claude-code/scripts/lib/agent_timeline.py +++ b/memind-integrations/claude-code/scripts/lib/agent_timeline.py @@ -332,7 +332,11 @@ def event_id(source_client, session_id, seq, hook_input, kind=None, text=None): def _base_event(hook_input, seq, kind, turn_id=None, turn_seq=None, text=None): source_client = hook_input.get("source_client") or "claude-code" session_id = hook_input.get("session_id") or "unknown-session" - metadata = {"hookEventName": hook_input.get("hook_event_name")} + metadata = { + "hookEventName": hook_input.get("hook_event_name"), + "sessionId": session_id, + "sourceClient": source_client, + } if turn_id: metadata["turnId"] = turn_id if turn_seq is not None: @@ -387,6 +391,74 @@ def normalize_stop_event(hook_input, seq, turn_id=None, turn_seq=None): return {key: value for key, value in event.items() if value is not None and value != ""} +def normalize_notification_event(hook_input, seq, turn_id=None, turn_seq=None): + text, redaction_kinds = redact_text( + hook_input.get("message") + or hook_input.get("notification") + or hook_input.get("text") + or "" + ) + event = _base_event(hook_input, seq, "notification", turn_id, turn_seq, text) + event["text"] = text + event["status"] = "success" + metadata = dict(event["metadata"]) + if _looks_blocking_notification(text): + metadata["notificationKind"] = "blocked" + metadata["failureSignal"] = text + else: + metadata["notificationKind"] = "info" + if redaction_kinds: + metadata["redacted"] = True + metadata["redactionKinds"] = sorted(set(redaction_kinds)) + event["metadata"] = metadata + return {key: value for key, value in event.items() if value is not None and value != ""} + + +def normalize_subagent_stop_event(hook_input, seq, turn_id=None, turn_seq=None): + subagent_type = ( + hook_input.get("subagent_type") + or hook_input.get("subagentType") + or hook_input.get("type") + ) + text, redaction_kinds = redact_text( + hook_input.get("message") + or hook_input.get("summary") + or hook_input.get("result") + or "" + ) + event = _base_event(hook_input, seq, "subagent_stop", turn_id, turn_seq, text) + event["text"] = text + event["operation"] = subagent_type + event["status"] = "success" + metadata = dict(event["metadata"]) + if subagent_type: + metadata["subagentType"] = subagent_type + if redaction_kinds: + metadata["redacted"] = True + metadata["redactionKinds"] = sorted(set(redaction_kinds)) + event["metadata"] = metadata + return {key: value for key, value in event.items() if value is not None and value != ""} + + +def normalize_compact_boundary_event(hook_input, seq, turn_id=None, turn_seq=None): + event = _base_event(hook_input, seq, "compact_boundary", turn_id, turn_seq, "compact") + event["status"] = "success" + event["operation"] = hook_input.get("trigger") or hook_input.get("compact_reason") or "compact" + return {key: value for key, value in event.items() if value is not None and value != ""} + + +def normalize_session_end_event(hook_input, seq, turn_id=None, turn_seq=None): + event = _base_event(hook_input, seq, "session_end", turn_id, turn_seq, "session_end") + event["status"] = "success" + event["operation"] = hook_input.get("reason") or hook_input.get("session_end_reason") or "session_end" + return {key: value for key, value in event.items() if value is not None and value != ""} + + +def _looks_blocking_notification(text): + lowered = (text or "").lower() + return any(token in lowered for token in ["permission", "blocked", "denied", "failed", "error"]) + + def normalize_hook_event(hook_input, seq, turn_id=None, turn_seq=None): source_client = hook_input.get("source_client") or "claude-code" session_id = hook_input.get("session_id") or "unknown-session" @@ -441,7 +513,11 @@ def normalize_hook_event(hook_input, seq, turn_id=None, turn_seq=None): event["output"] = _json_text(redacted_output) redaction_kinds.extend(kinds) - metadata = {"hookEventName": hook_input.get("hook_event_name")} + metadata = { + "hookEventName": hook_input.get("hook_event_name"), + "sessionId": session_id, + "sourceClient": source_client, + } normalization_metadata, kinds = _redact_metadata(normalization.get("metadata") or {}) metadata.update(normalization_metadata) redaction_kinds.extend(kinds) @@ -478,6 +554,8 @@ def build_timeline_payload(config, identity, session_id, events, hook_input): "metadata": { "userId": identity.get("userId"), "agentId": identity.get("agentId"), + "sessionId": session_id, + "sourceClient": source_client, "eventIds": [event["eventId"] for event in events if event.get("eventId")], }, } diff --git a/memind-integrations/claude-code/scripts/lib/config.py b/memind-integrations/claude-code/scripts/lib/config.py index 22a093c3..fceade2a 100644 --- a/memind-integrations/claude-code/scripts/lib/config.py +++ b/memind-integrations/claude-code/scripts/lib/config.py @@ -21,7 +21,6 @@ "memindApiToken": None, "userId": None, "agentId": "claude-code", - "agentIdMode": "project", "sourceClient": "claude-code", "autoRetrieve": True, "autoIngestAgentTimeline": True, @@ -42,7 +41,6 @@ "MEMIND_API_TOKEN": ("memindApiToken", str), "MEMIND_USER_ID": ("userId", str), "MEMIND_AGENT_ID": ("agentId", str), - "MEMIND_AGENT_ID_MODE": ("agentIdMode", str), "MEMIND_SOURCE_CLIENT": ("sourceClient", str), "MEMIND_AUTO_RETRIEVE": ("autoRetrieve", "bool"), "MEMIND_AUTO_INGEST_AGENT_TIMELINE": ("autoIngestAgentTimeline", "bool"), diff --git a/memind-integrations/claude-code/scripts/lib/identity.py b/memind-integrations/claude-code/scripts/lib/identity.py index c36c4fa6..4eac8ef6 100644 --- a/memind-integrations/claude-code/scripts/lib/identity.py +++ b/memind-integrations/claude-code/scripts/lib/identity.py @@ -53,8 +53,5 @@ def resolve_identity(config, hook_input): cwd = hook_input.get("cwd") or os.getcwd() user_id = config.get("userId") or f"local{SEPARATOR}{getpass.getuser()}" base_agent = config.get("agentId") or "claude-code" - if config.get("agentIdMode") == "project": - agent_id = f"{base_agent}{SEPARATOR}{project_slug(cwd)}" - else: - agent_id = base_agent + agent_id = f"{base_agent}{SEPARATOR}{project_slug(cwd)}" return {"userId": user_id, "agentId": agent_id} diff --git a/memind-integrations/claude-code/scripts/lib/state.py b/memind-integrations/claude-code/scripts/lib/state.py index 4f4e4986..4e51ca23 100644 --- a/memind-integrations/claude-code/scripts/lib/state.py +++ b/memind-integrations/claude-code/scripts/lib/state.py @@ -41,8 +41,10 @@ def append_agent_event(self, event): return events.append(event) if len(events) > MAX_AGENT_EVENTS: + dropped = len(events) - MAX_AGENT_EVENTS events = events[-MAX_AGENT_EVENTS:] self.data["agentEventsTruncated"] = True + self.data["agentEventsDropped"] = int(self.data.get("agentEventsDropped", 0)) + dropped self.data["agentEvents"] = events self.data["updatedAt"] = time.time() @@ -95,6 +97,13 @@ def close_agent_turn(self, turn_id=None): self.data.pop("currentAgentTurnSeq", None) self.data["updatedAt"] = time.time() + def is_empty(self): + return ( + not self.data.get("agentEvents") + and not self.data.get("currentAgentTurnId") + and not self.data.get("currentAgentTurnSeq") + ) + class SessionStateStore: def __init__(self, root): diff --git a/memind-integrations/claude-code/scripts/notification.py b/memind-integrations/claude-code/scripts/notification.py new file mode 100644 index 00000000..e5018eca --- /dev/null +++ b/memind-integrations/claude-code/scripts/notification.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from ingest import state_root +from lib.agent_timeline import normalize_notification_event +from lib.config import load_config +from lib.logging_utils import debug_log +from lib.state import SessionStateStore + + +def main(): + try: + hook_input = json.loads(sys.stdin.read() or "{}") + config = load_config() + session_id = hook_input.get("session_id") or "unknown-session" + hook_input["source_client"] = config.get("sourceClient") or "claude-code" + with SessionStateStore(state_root()).locked(session_id) as state: + turn_id, turn_seq = state.ensure_agent_turn(session_id) + seq = state.next_agent_seq() + state.append_agent_event( + normalize_notification_event( + hook_input, seq, turn_id=turn_id, turn_seq=turn_seq + ) + ) + except Exception as exc: + try: + debug_log(load_config(), "notification_failed", {"error": str(exc)}) + except Exception: + pass + print(json.dumps({"continue": True})) + + +if __name__ == "__main__": + main() diff --git a/memind-integrations/claude-code/scripts/subagent_stop.py b/memind-integrations/claude-code/scripts/subagent_stop.py new file mode 100644 index 00000000..a1894691 --- /dev/null +++ b/memind-integrations/claude-code/scripts/subagent_stop.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from ingest import state_root +from lib.agent_timeline import normalize_subagent_stop_event +from lib.config import load_config +from lib.logging_utils import debug_log +from lib.state import SessionStateStore + + +def main(): + try: + hook_input = json.loads(sys.stdin.read() or "{}") + config = load_config() + session_id = hook_input.get("session_id") or "unknown-session" + hook_input["source_client"] = config.get("sourceClient") or "claude-code" + with SessionStateStore(state_root()).locked(session_id) as state: + turn_id, turn_seq = state.ensure_agent_turn(session_id) + seq = state.next_agent_seq() + state.append_agent_event( + normalize_subagent_stop_event( + hook_input, seq, turn_id=turn_id, turn_seq=turn_seq + ) + ) + except Exception as exc: + try: + debug_log(load_config(), "subagent_stop_failed", {"error": str(exc)}) + except Exception: + pass + print(json.dumps({"continue": True})) + + +if __name__ == "__main__": + main() diff --git a/memind-integrations/claude-code/settings.json b/memind-integrations/claude-code/settings.json index bbe89c51..1ab8fce5 100644 --- a/memind-integrations/claude-code/settings.json +++ b/memind-integrations/claude-code/settings.json @@ -3,7 +3,6 @@ "memindApiToken": null, "userId": null, "agentId": "claude-code", - "agentIdMode": "project", "sourceClient": "claude-code", "autoRetrieve": true, "autoIngestAgentTimeline": true, diff --git a/memind-integrations/claude-code/tests/test_agent_timeline.py b/memind-integrations/claude-code/tests/test_agent_timeline.py index dfe097e1..b7124731 100644 --- a/memind-integrations/claude-code/tests/test_agent_timeline.py +++ b/memind-integrations/claude-code/tests/test_agent_timeline.py @@ -18,8 +18,12 @@ from scripts.lib.agent_timeline import ( build_timeline_payload, normalize_assistant_message_event, + normalize_compact_boundary_event, normalize_hook_event, + normalize_notification_event, + normalize_session_end_event, normalize_stop_event, + normalize_subagent_stop_event, normalize_user_prompt_event, ) @@ -50,6 +54,8 @@ def test_normalizes_post_tool_use_to_test_result_event(self): self.assertEqual(event["output"], '{"stdout": "rounding mismatch"}') self.assertEqual(event["metadata"]["validationType"], "test") self.assertEqual(event["metadata"]["normalizationVersion"], 1) + self.assertEqual(event["metadata"]["sessionId"], "s") + self.assertEqual(event["metadata"]["sourceClient"], "claude-code") self.assertEqual(event["metadata"]["turnId"], "s-turn-1") self.assertEqual(event["metadata"]["turnSeq"], 1) @@ -207,10 +213,14 @@ def test_normalizes_user_prompt_and_stop_events_with_turn_metadata(self): self.assertEqual(prompt_event["kind"], "user_prompt") self.assertEqual(prompt_event["text"], "Fix payment tests") + self.assertEqual(prompt_event["metadata"]["sessionId"], "s") + self.assertEqual(prompt_event["metadata"]["sourceClient"], "claude-code") self.assertEqual(prompt_event["metadata"]["turnId"], "s-turn-1") self.assertEqual(prompt_event["metadata"]["turnSeq"], 1) self.assertEqual(stop_event["kind"], "stop") self.assertEqual(stop_event["status"], "success") + self.assertEqual(stop_event["metadata"]["sessionId"], "s") + self.assertEqual(stop_event["metadata"]["sourceClient"], "claude-code") self.assertEqual(stop_event["metadata"]["turnId"], "s-turn-1") def test_normalizes_assistant_message_event_from_transcript_text(self): @@ -229,8 +239,80 @@ def test_normalizes_assistant_message_event_from_transcript_text(self): self.assertEqual(event["kind"], "assistant_message") self.assertEqual(event["text"], "Updated calc.ts and tests now pass.") self.assertEqual(event["status"], "success") + self.assertEqual(event["metadata"]["sessionId"], "s") + self.assertEqual(event["metadata"]["sourceClient"], "claude-code") self.assertEqual(event["metadata"]["turnId"], "s-turn-1") + def test_normalizes_notification_event(self): + event = normalize_notification_event( + { + "hook_event_name": "Notification", + "session_id": "s", + "message": "Claude needs permission to run Bash", + "timestamp": "2026-05-24T10:00:00Z", + }, + seq=1, + turn_id="s-turn-1", + turn_seq=1, + ) + + self.assertEqual(event["kind"], "notification") + self.assertEqual(event["text"], "Claude needs permission to run Bash") + self.assertEqual(event["status"], "success") + self.assertEqual(event["metadata"]["notificationKind"], "blocked") + self.assertEqual(event["metadata"]["failureSignal"], "Claude needs permission to run Bash") + + def test_normalizes_subagent_stop_event(self): + event = normalize_subagent_stop_event( + { + "hook_event_name": "SubagentStop", + "session_id": "s", + "subagent_type": "explorer", + "message": "Found failing resolver test", + "timestamp": "2026-05-24T10:01:00Z", + }, + seq=2, + turn_id="s-turn-1", + turn_seq=1, + ) + + self.assertEqual(event["kind"], "subagent_stop") + self.assertEqual(event["operation"], "explorer") + self.assertIn("Found failing resolver test", event["text"]) + self.assertEqual(event["metadata"]["subagentType"], "explorer") + + def test_normalizes_compact_boundary_event(self): + event = normalize_compact_boundary_event( + { + "hook_event_name": "PreCompact", + "session_id": "s", + "timestamp": "2026-05-24T10:02:00Z", + }, + seq=3, + turn_id="s-turn-1", + turn_seq=1, + ) + + self.assertEqual(event["kind"], "compact_boundary") + self.assertEqual(event["status"], "success") + self.assertEqual(event["operation"], "compact") + + def test_normalizes_session_end_event(self): + event = normalize_session_end_event( + { + "hook_event_name": "SessionEnd", + "session_id": "s", + "timestamp": "2026-05-24T10:03:00Z", + }, + seq=4, + turn_id="s-turn-1", + turn_seq=1, + ) + + self.assertEqual(event["kind"], "session_end") + self.assertEqual(event["status"], "success") + self.assertEqual(event["operation"], "session_end") + def test_redacts_secret_fields_before_spool(self): event = normalize_hook_event( { @@ -281,8 +363,11 @@ def test_builds_agent_timeline_payload(self): self.assertEqual(payload["sessionId"], "s") self.assertEqual(payload["agentTurnId"], "s-turn-2") self.assertEqual(payload["timelineId"], "s-turn-2-timeline") + self.assertEqual(payload["metadata"]["sessionId"], "s") + self.assertEqual(payload["metadata"]["sourceClient"], "claude-code") self.assertEqual(payload["metadata"]["turnId"], "s-turn-2") self.assertEqual(payload["metadata"]["turnSeq"], 2) + self.assertEqual(payload["metadata"]["eventIds"], [event["eventId"]]) self.assertIn("eventId", payload["events"][0]) self.assertEqual(payload["events"][0]["seq"], 1) self.assertEqual(payload["project"]["name"], "project") diff --git a/memind-integrations/claude-code/tests/test_config.py b/memind-integrations/claude-code/tests/test_config.py index 2f82a5cb..01819bc1 100644 --- a/memind-integrations/claude-code/tests/test_config.py +++ b/memind-integrations/claude-code/tests/test_config.py @@ -44,6 +44,7 @@ def test_defaults_match_spec(self): self.assertEqual(DEFAULT_SETTINGS["retrieveContextTurns"], 0) self.assertEqual(DEFAULT_SETTINGS["sourceClient"], "claude-code") self.assertTrue(DEFAULT_SETTINGS["autoIngestAgentTimeline"]) + self.assertNotIn("agentIdMode", DEFAULT_SETTINGS) self.assertNotIn("autoIngest", DEFAULT_SETTINGS) self.assertNotIn("ingestionRoles", DEFAULT_SETTINGS) self.assertNotIn("ingestionMaxMessagesPerHook", DEFAULT_SETTINGS) @@ -64,6 +65,7 @@ def test_environment_overrides(self): self.assertEqual(config["memindApiUrl"], "http://memind.example") self.assertFalse(config["autoRetrieve"]) self.assertFalse(config["autoIngestAgentTimeline"]) + self.assertNotIn("agentIdMode", config) self.assertNotIn("ingestionRoles", config) self.assertEqual(config["stateMaxAgeDays"], 30) self.assertEqual(config["retrieveMaxEntries"], 3) diff --git a/memind-integrations/claude-code/tests/test_hooks.py b/memind-integrations/claude-code/tests/test_hooks.py index 8c846f63..f59db166 100644 --- a/memind-integrations/claude-code/tests/test_hooks.py +++ b/memind-integrations/claude-code/tests/test_hooks.py @@ -186,6 +186,55 @@ def test_post_tool_use_fails_open(self): self.assertEqual(event["kind"], "test_result") self.assertEqual(event["status"], "success") + def test_notification_buffers_event(self): + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + env = { + "CLAUDE_PLUGIN_ROOT": str(ROOT), + "PYTHONPATH": str(ROOT), + "MEMIND_CLAUDE_STATE_ROOT": str(state_dir), + } + output = self.run_hook( + "notification.py", + { + "hook_event_name": "Notification", + "cwd": tmp, + "session_id": "s1", + "message": "Permission required for Bash", + }, + env=env, + ) + self.assertEqual(output, {"continue": True}) + state_file = next(state_dir.glob("*.json")) + event = json.loads(state_file.read_text())["agentEvents"][0] + self.assertEqual(event["kind"], "notification") + self.assertEqual(event["metadata"]["notificationKind"], "blocked") + + def test_subagent_stop_buffers_event(self): + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + env = { + "CLAUDE_PLUGIN_ROOT": str(ROOT), + "PYTHONPATH": str(ROOT), + "MEMIND_CLAUDE_STATE_ROOT": str(state_dir), + } + output = self.run_hook( + "subagent_stop.py", + { + "hook_event_name": "SubagentStop", + "cwd": tmp, + "session_id": "s1", + "subagent_type": "explorer", + "message": "Found failing resolver test", + }, + env=env, + ) + self.assertEqual(output, {"continue": True}) + state_file = next(state_dir.glob("*.json")) + event = json.loads(state_file.read_text())["agentEvents"][0] + self.assertEqual(event["kind"], "subagent_stop") + self.assertEqual(event["operation"], "explorer") + def test_retrieve_buffers_user_prompt_event_before_memory_lookup(self): with tempfile.TemporaryDirectory() as tmp: state_dir = Path(tmp) / "state" @@ -363,6 +412,97 @@ def test_ingest_flushes_agent_timeline_and_clears_events_on_success(self): self.assertEqual(raw_content["metadata"]["turnId"], "s1-turn-1") with SessionStateStore(state_dir).locked("s1") as state: self.assertEqual(state.agent_events(), []) + self.assertTrue(state.is_empty()) + + def test_pre_compact_appends_compact_boundary_before_flush(self): + sys.path.insert(0, str(ROOT / "scripts")) + import ingest + from scripts.lib.state import SessionStateStore + + config = { + "memindApiUrl": "http://127.0.0.1:8366", + "memindApiToken": None, + "autoIngestAgentTimeline": True, + "ingestRetrySpool": False, + "sourceClient": "claude-code", + "agentId": "claude-code", + "userId": "u", + } + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + with SessionStateStore(state_dir).locked("s1") as state: + state.append_agent_event( + { + "eventId": "e1", + "seq": 1, + "kind": "user_prompt", + "text": "Continue before compaction", + "metadata": {"turnId": "s1-turn-1", "turnSeq": 1}, + } + ) + with mock.patch.object(ingest, "state_root", return_value=state_dir): + with mock.patch.object(ingest, "retry_root", return_value=Path(tmp) / "retry"): + with mock.patch.object(ingest, "MemindClient") as client_cls: + client = client_cls.return_value + client.extract = mock.AsyncMock(return_value=types.SimpleNamespace(status="SUCCESS")) + result = ingest.ingest_messages( + config, + { + "hook_event_name": "PreCompact", + "session_id": "s1", + "cwd": tmp, + "timestamp": "2026-05-24T10:04:00Z", + }, + ) + self.assertEqual(result["agentEventsSubmitted"], 2) + client.extract.assert_awaited_once() + raw_content = client.extract.await_args.args[2] + self.assertEqual(raw_content["events"][-1]["kind"], "compact_boundary") + + def test_session_end_appends_session_end_before_flush(self): + sys.path.insert(0, str(ROOT / "scripts")) + import ingest + from scripts.lib.state import SessionStateStore + + config = { + "memindApiUrl": "http://127.0.0.1:8366", + "memindApiToken": None, + "autoIngestAgentTimeline": True, + "ingestRetrySpool": False, + "sourceClient": "claude-code", + "agentId": "claude-code", + "userId": "u", + } + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + with SessionStateStore(state_dir).locked("s1") as state: + state.append_agent_event( + { + "eventId": "e1", + "seq": 1, + "kind": "command", + "command": "npm test payment", + "metadata": {"turnId": "s1-turn-1", "turnSeq": 1}, + } + ) + with mock.patch.object(ingest, "state_root", return_value=state_dir): + with mock.patch.object(ingest, "retry_root", return_value=Path(tmp) / "retry"): + with mock.patch.object(ingest, "MemindClient") as client_cls: + client = client_cls.return_value + client.extract = mock.AsyncMock(return_value=types.SimpleNamespace(status="SUCCESS")) + result = ingest.ingest_messages( + config, + { + "hook_event_name": "SessionEnd", + "session_id": "s1", + "cwd": tmp, + "timestamp": "2026-05-24T10:05:00Z", + }, + ) + self.assertEqual(result["agentEventsSubmitted"], 2) + client.extract.assert_awaited_once() + raw_content = client.extract.await_args.args[2] + self.assertEqual(raw_content["events"][-1]["kind"], "session_end") def test_ingest_spools_agent_timeline_on_partial_success(self): sys.path.insert(0, str(ROOT / "scripts")) diff --git a/memind-integrations/claude-code/tests/test_identity.py b/memind-integrations/claude-code/tests/test_identity.py index 8ef7da48..a40c6f91 100644 --- a/memind-integrations/claude-code/tests/test_identity.py +++ b/memind-integrations/claude-code/tests/test_identity.py @@ -20,14 +20,20 @@ class IdentityTest(unittest.TestCase): - def test_resolve_identity_uses_project_slug(self): + def test_resolve_identity_always_uses_project_slug(self): with tempfile.TemporaryDirectory() as tmp: - identity = resolve_identity({"agentId": "claude-code", "agentIdMode": "project"}, {"cwd": tmp}) + identity = resolve_identity({"agentId": "claude-code"}, {"cwd": tmp}) self.assertTrue(identity["userId"].startswith("local__")) self.assertTrue(identity["agentId"].startswith("claude-code__")) + self.assertNotEqual(identity["agentId"], "claude-code") self.assertNotIn(":", identity["userId"]) self.assertNotIn(":", identity["agentId"]) + def test_agent_id_mode_is_ignored_for_backward_safety(self): + with tempfile.TemporaryDirectory() as tmp: + identity = resolve_identity({"agentId": "claude-code", "agentIdMode": "global"}, {"cwd": tmp}) + self.assertTrue(identity["agentId"].startswith("claude-code__")) + def test_project_slug_has_stable_suffix(self): with tempfile.TemporaryDirectory() as tmp: first = project_slug(Path(tmp)) diff --git a/memind-integrations/claude-code/tests/test_manifest.py b/memind-integrations/claude-code/tests/test_manifest.py index 24c0cfb8..38ed3ea2 100644 --- a/memind-integrations/claude-code/tests/test_manifest.py +++ b/memind-integrations/claude-code/tests/test_manifest.py @@ -32,6 +32,8 @@ def test_hooks_json_shape(self): "UserPromptSubmit", "PreToolUse", "PostToolUse", + "Notification", + "SubagentStop", "PreCompact", "Stop", "SessionEnd", @@ -45,11 +47,16 @@ def test_hooks_json_shape(self): self.assertTrue(stop_command["async"]) self.assertTrue(hooks["PreToolUse"][0]["hooks"][0]["async"]) self.assertTrue(hooks["PostToolUse"][0]["hooks"][0]["async"]) + self.assertTrue(hooks["Notification"][0]["hooks"][0]["async"]) + self.assertTrue(hooks["SubagentStop"][0]["hooks"][0]["async"]) + self.assertEqual(hooks["Notification"][0]["hooks"][0]["timeout"], 5) + self.assertEqual(hooks["SubagentStop"][0]["hooks"][0]["timeout"], 5) def test_default_settings(self): settings = json.loads((ROOT / "settings.json").read_text()) self.assertEqual(settings["retrieveContextTurns"], 0) self.assertTrue(settings["autoIngestAgentTimeline"]) + self.assertNotIn("agentIdMode", settings) self.assertNotIn("autoIngest", settings) self.assertNotIn("ingestionRoles", settings) self.assertNotIn("ingestionMaxMessagesPerHook", settings) diff --git a/memind-integrations/claude-code/tests/test_state.py b/memind-integrations/claude-code/tests/test_state.py index 0515f601..69d7e138 100644 --- a/memind-integrations/claude-code/tests/test_state.py +++ b/memind-integrations/claude-code/tests/test_state.py @@ -48,6 +48,16 @@ def test_agent_events_are_deduplicated_and_clear_by_event_id(self): with store.locked("session-1") as state: self.assertEqual(state.agent_events(), [{"eventId": "e2", "seq": 2}]) + def test_state_reports_empty_after_all_events_are_cleared_and_turn_closed(self): + with tempfile.TemporaryDirectory() as tmp: + store = SessionStateStore(Path(tmp)) + with store.locked("session-1") as state: + turn_id, _turn_seq = state.start_agent_turn("session-1") + state.append_agent_event({"eventId": "e1", "seq": 1}) + state.clear_agent_events(["e1"]) + state.close_agent_turn(turn_id) + self.assertTrue(state.is_empty()) + def test_agent_event_buffer_has_soft_cap(self): with tempfile.TemporaryDirectory() as tmp: store = SessionStateStore(Path(tmp)) @@ -59,6 +69,24 @@ def test_agent_event_buffer_has_soft_cap(self): self.assertEqual(len(events), 500) self.assertEqual(events[0]["eventId"], "e1") self.assertTrue(state.data["agentEventsTruncated"]) + self.assertEqual(state.data["agentEventsDropped"], 1) + + def test_agent_event_buffer_soft_cap_preserves_newest_boundary(self): + with tempfile.TemporaryDirectory() as tmp: + store = SessionStateStore(Path(tmp)) + with store.locked("session-1") as state: + state.append_agent_event({"eventId": "prompt", "seq": 1, "kind": "user_prompt"}) + for index in range(600): + state.append_agent_event( + {"eventId": f"e{index}", "seq": index + 2, "kind": "tool_result"} + ) + state.append_agent_event({"eventId": "stop", "seq": 700, "kind": "stop"}) + with store.locked("session-1") as state: + events = state.agent_events() + self.assertLessEqual(len(events), 500) + self.assertEqual(events[-1]["eventId"], "stop") + self.assertTrue(state.data["agentEventsTruncated"]) + self.assertIn("agentEventsDropped", state.data) if __name__ == "__main__": diff --git a/memind-integrations/codex/README.md b/memind-integrations/codex/README.md index c21cdc53..702bd6fa 100644 --- a/memind-integrations/codex/README.md +++ b/memind-integrations/codex/README.md @@ -126,6 +126,10 @@ The installed hooks are: | `PostToolUse` | `scripts/post_tool_use.py` | 5s | Buffer a redacted tool-result event in local session state. | | `Stop` | `scripts/ingest.py` | 15s | Flush buffered `agent_timeline` events after a turn. | +Codex currently registers only the hook events listed above. Memind does not simulate Claude Code-only lifecycle +events such as `PreCompact`, `SessionEnd`, `Notification`, or `SubagentStop` in the Codex adapter. If Codex adds +native support for additional lifecycle events, they should be added as explicit hooks with tests. + ## Configuration The default configuration works with a local Memind server at `http://127.0.0.1:8366`. @@ -138,7 +142,6 @@ User configuration is optional. Save overrides as `~/.memind/codex.json`: "memindApiToken": null, "userId": "local__alice", "agentId": "codex", - "agentIdMode": "project", "sourceClient": "codex", "autoIngestAgentTimeline": true, "retrieveContextTurns": 0 @@ -158,8 +161,7 @@ Settings are loaded in this order: | `memindApiUrl` | `http://127.0.0.1:8366` | Memind server URL. | | `memindApiToken` | `null` | Optional bearer token. | | `userId` | `local__` | Memind user identity. | -| `agentId` | `codex` | Base agent identity. | -| `agentIdMode` | `project` | `project` appends a stable project suffix; any other value uses `agentId` as-is. | +| `agentId` | `codex` | Base agent identity. A stable project suffix is always appended before calling Memind. | | `sourceClient` | `codex` | Source marker stored with Memind data. | | `autoRetrieve` | `true` | Enables prompt-time memory retrieval. | | `autoIngestAgentTimeline` | `true` | Enables user prompt, tool/result, assistant message, and stop event buffering plus `agent_timeline` rawdata flush. | @@ -179,7 +181,6 @@ export MEMIND_API_URL=http://127.0.0.1:8366 export MEMIND_API_TOKEN=... export MEMIND_USER_ID=local__alice export MEMIND_AGENT_ID=codex -export MEMIND_AGENT_ID_MODE=project export MEMIND_SOURCE_CLIENT=codex export MEMIND_AUTO_INGEST_AGENT_TIMELINE=true export MEMIND_RETRIEVE_CONTEXT_TURNS=0 @@ -201,14 +202,10 @@ By default, Memind stores Codex memory under: The project hash is based on the Git remote URL when available, otherwise the local project path. This keeps different repositories separated while allowing memory to survive moving between Codex sessions. -To use one shared Codex memory across all projects: - -```json -{ - "agentId": "codex", - "agentIdMode": "fixed" -} -``` +`agentId` in configuration is the base identity only. The runtime always appends the project suffix before +retrieval or ingestion, so this integration does not provide a global, all-project Codex memory mode. +`sessionId`, `agentTurnId`, `timelineId`, and per-event turn metadata are stored only inside raw content and +item metadata; they do not create Memind core project or session entities. ## Retrieval Behavior @@ -242,9 +239,12 @@ Agent memory items are grouped separately when returned by Memind: ## Directives ``` +This phase keeps retrieval formatting intentionally simple. It does not add a new Retrieval Context Compiler; +retrieved memories are still formatted from Memind items and insights by the adapter. + ## Ingestion Behavior -Ingestion is timeline-only for Codex. The plugin does not submit transcript conversation rawdata. It buffers one +Ingestion is timeline-only for Codex. The plugin does not submit transcript conversation-style raw data. It buffers one turn timeline under `~/.memind/codex/state/`: the submitted user prompt, tool and command events, the latest assistant message when available from the transcript, and a stop boundary. It flushes the turn through `AsyncMemindClient.memory.extract(...)` as agent timeline rawdata. A typical timeline payload looks like: @@ -302,7 +302,7 @@ assistant message when available from the transcript, and a stop boundary. It fl ``` Secrets are redacted before events are written to local state. File content capture is disabled by default; the -hook stores normalized tool metadata, commands, paths, statuses, and compact outputs. +hook stores normalized tool metadata, commands, paths, statuses, and bounded outputs. On `SUCCESS`, the covered events are removed from local state. `PARTIAL_SUCCESS` and failures keep the events available and spool the full timeline payload for later `SessionStart` replay. @@ -419,7 +419,7 @@ curl -fsSL http://127.0.0.1:8366/open/v1/health ``` - Confirm `autoRetrieve` is `true`. -- Confirm existing memories are stored under the same `userId` and `agentId`. +- Confirm existing memories are stored under the same `userId` and resolved project-scoped `agentId`. - Try setting `retrieveContextTurns` to `1` or `2` if the current prompt is very short. ### Agent timeline events are not ingested diff --git a/memind-integrations/codex/scripts/lib/agent_timeline.py b/memind-integrations/codex/scripts/lib/agent_timeline.py index c3a18333..ad0e8ea2 100644 --- a/memind-integrations/codex/scripts/lib/agent_timeline.py +++ b/memind-integrations/codex/scripts/lib/agent_timeline.py @@ -332,7 +332,11 @@ def event_id(source_client, session_id, seq, hook_input, kind=None, text=None): def _base_event(hook_input, seq, kind, turn_id=None, turn_seq=None, text=None): source_client = hook_input.get("source_client") or "codex" session_id = hook_input.get("session_id") or "unknown-session" - metadata = {"hookEventName": hook_input.get("hook_event_name")} + metadata = { + "hookEventName": hook_input.get("hook_event_name"), + "sessionId": session_id, + "sourceClient": source_client, + } if turn_id: metadata["turnId"] = turn_id if turn_seq is not None: @@ -387,6 +391,20 @@ def normalize_stop_event(hook_input, seq, turn_id=None, turn_seq=None): return {key: value for key, value in event.items() if value is not None and value != ""} +def normalize_compact_boundary_event(hook_input, seq, turn_id=None, turn_seq=None): + event = _base_event(hook_input, seq, "compact_boundary", turn_id, turn_seq, "compact") + event["status"] = "success" + event["operation"] = hook_input.get("trigger") or hook_input.get("compact_reason") or "compact" + return {key: value for key, value in event.items() if value is not None and value != ""} + + +def normalize_session_end_event(hook_input, seq, turn_id=None, turn_seq=None): + event = _base_event(hook_input, seq, "session_end", turn_id, turn_seq, "session_end") + event["status"] = "success" + event["operation"] = hook_input.get("reason") or hook_input.get("session_end_reason") or "session_end" + return {key: value for key, value in event.items() if value is not None and value != ""} + + def normalize_hook_event(hook_input, seq, turn_id=None, turn_seq=None): source_client = hook_input.get("source_client") or "codex" session_id = hook_input.get("session_id") or "unknown-session" @@ -441,7 +459,11 @@ def normalize_hook_event(hook_input, seq, turn_id=None, turn_seq=None): event["output"] = _json_text(redacted_output) redaction_kinds.extend(kinds) - metadata = {"hookEventName": hook_input.get("hook_event_name")} + metadata = { + "hookEventName": hook_input.get("hook_event_name"), + "sessionId": session_id, + "sourceClient": source_client, + } normalization_metadata, kinds = _redact_metadata(normalization.get("metadata") or {}) metadata.update(normalization_metadata) redaction_kinds.extend(kinds) @@ -478,6 +500,8 @@ def build_timeline_payload(config, identity, session_id, events, hook_input): "metadata": { "userId": identity.get("userId"), "agentId": identity.get("agentId"), + "sessionId": session_id, + "sourceClient": source_client, "eventIds": [event["eventId"] for event in events if event.get("eventId")], }, } diff --git a/memind-integrations/codex/scripts/lib/config.py b/memind-integrations/codex/scripts/lib/config.py index 546f3465..d9b17a39 100644 --- a/memind-integrations/codex/scripts/lib/config.py +++ b/memind-integrations/codex/scripts/lib/config.py @@ -21,7 +21,6 @@ "memindApiToken": None, "userId": None, "agentId": "codex", - "agentIdMode": "project", "sourceClient": "codex", "autoRetrieve": True, "autoIngestAgentTimeline": True, @@ -42,7 +41,6 @@ "MEMIND_API_TOKEN": ("memindApiToken", str), "MEMIND_USER_ID": ("userId", str), "MEMIND_AGENT_ID": ("agentId", str), - "MEMIND_AGENT_ID_MODE": ("agentIdMode", str), "MEMIND_SOURCE_CLIENT": ("sourceClient", str), "MEMIND_AUTO_RETRIEVE": ("autoRetrieve", "bool"), "MEMIND_AUTO_INGEST_AGENT_TIMELINE": ("autoIngestAgentTimeline", "bool"), diff --git a/memind-integrations/codex/scripts/lib/identity.py b/memind-integrations/codex/scripts/lib/identity.py index b5c59981..7b3b4a52 100644 --- a/memind-integrations/codex/scripts/lib/identity.py +++ b/memind-integrations/codex/scripts/lib/identity.py @@ -53,8 +53,5 @@ def resolve_identity(config, hook_input): cwd = hook_input.get("cwd") or os.getcwd() user_id = config.get("userId") or f"local{SEPARATOR}{getpass.getuser()}" base_agent = config.get("agentId") or "codex" - if config.get("agentIdMode") == "project": - agent_id = f"{base_agent}{SEPARATOR}{project_slug(cwd)}" - else: - agent_id = base_agent + agent_id = f"{base_agent}{SEPARATOR}{project_slug(cwd)}" return {"userId": user_id, "agentId": agent_id} diff --git a/memind-integrations/codex/scripts/lib/state.py b/memind-integrations/codex/scripts/lib/state.py index 4fd7dbb0..bc77572e 100644 --- a/memind-integrations/codex/scripts/lib/state.py +++ b/memind-integrations/codex/scripts/lib/state.py @@ -97,8 +97,10 @@ def append_agent_event(self, event): return events.append(event) if len(events) > MAX_AGENT_EVENTS: + dropped = len(events) - MAX_AGENT_EVENTS events = events[-MAX_AGENT_EVENTS:] self.data["agentEventsTruncated"] = True + self.data["agentEventsDropped"] = int(self.data.get("agentEventsDropped", 0)) + dropped self.data["agentEvents"] = events self.data["updatedAt"] = time.time() @@ -151,6 +153,13 @@ def close_agent_turn(self, turn_id=None): self.data.pop("currentAgentTurnSeq", None) self.data["updatedAt"] = time.time() + def is_empty(self): + return ( + not self.data.get("agentEvents") + and not self.data.get("currentAgentTurnId") + and not self.data.get("currentAgentTurnSeq") + ) + class SessionStateStore: def __init__(self, root): diff --git a/memind-integrations/codex/settings.json b/memind-integrations/codex/settings.json index 86273ac9..38481728 100644 --- a/memind-integrations/codex/settings.json +++ b/memind-integrations/codex/settings.json @@ -3,7 +3,6 @@ "memindApiToken": null, "userId": null, "agentId": "codex", - "agentIdMode": "project", "sourceClient": "codex", "autoRetrieve": true, "autoIngestAgentTimeline": true, diff --git a/memind-integrations/codex/tests/test_agent_timeline.py b/memind-integrations/codex/tests/test_agent_timeline.py index e917224d..a299af4e 100644 --- a/memind-integrations/codex/tests/test_agent_timeline.py +++ b/memind-integrations/codex/tests/test_agent_timeline.py @@ -18,7 +18,9 @@ from scripts.lib.agent_timeline import ( build_timeline_payload, normalize_assistant_message_event, + normalize_compact_boundary_event, normalize_hook_event, + normalize_session_end_event, normalize_stop_event, normalize_user_prompt_event, ) @@ -51,6 +53,8 @@ def test_normalizes_post_tool_use_to_test_result_event(self): self.assertEqual(event["output"], '{"stderr": "rounding mismatch"}') self.assertEqual(event["metadata"]["validationType"], "test") self.assertEqual(event["metadata"]["normalizationVersion"], 1) + self.assertEqual(event["metadata"]["sessionId"], "s") + self.assertEqual(event["metadata"]["sourceClient"], "codex") self.assertEqual(event["metadata"]["turnId"], "s-turn-1") self.assertEqual(event["metadata"]["turnSeq"], 1) @@ -217,10 +221,14 @@ def test_normalizes_user_prompt_and_stop_events_with_turn_metadata(self): self.assertEqual(prompt_event["kind"], "user_prompt") self.assertEqual(prompt_event["text"], "Fix payment tests") + self.assertEqual(prompt_event["metadata"]["sessionId"], "s") + self.assertEqual(prompt_event["metadata"]["sourceClient"], "codex") self.assertEqual(prompt_event["metadata"]["turnId"], "s-turn-1") self.assertEqual(prompt_event["metadata"]["turnSeq"], 1) self.assertEqual(stop_event["kind"], "stop") self.assertEqual(stop_event["status"], "success") + self.assertEqual(stop_event["metadata"]["sessionId"], "s") + self.assertEqual(stop_event["metadata"]["sourceClient"], "codex") self.assertEqual(stop_event["metadata"]["turnId"], "s-turn-1") def test_normalizes_assistant_message_event_from_transcript_text(self): @@ -240,8 +248,42 @@ def test_normalizes_assistant_message_event_from_transcript_text(self): self.assertEqual(event["kind"], "assistant_message") self.assertEqual(event["text"], "Updated calc.ts and tests now pass.") self.assertEqual(event["status"], "success") + self.assertEqual(event["metadata"]["sessionId"], "s") + self.assertEqual(event["metadata"]["sourceClient"], "codex") self.assertEqual(event["metadata"]["turnId"], "s-turn-1") + def test_normalizes_compact_boundary_event_for_parser_compatibility(self): + event = normalize_compact_boundary_event( + { + "hook_event_name": "PreCompact", + "session_id": "s", + "timestamp": "2026-05-24T10:02:00Z", + }, + seq=3, + turn_id="s-turn-1", + turn_seq=1, + ) + + self.assertEqual(event["kind"], "compact_boundary") + self.assertEqual(event["status"], "success") + self.assertEqual(event["operation"], "compact") + + def test_normalizes_session_end_event_for_parser_compatibility(self): + event = normalize_session_end_event( + { + "hook_event_name": "SessionEnd", + "session_id": "s", + "timestamp": "2026-05-24T10:03:00Z", + }, + seq=4, + turn_id="s-turn-1", + turn_seq=1, + ) + + self.assertEqual(event["kind"], "session_end") + self.assertEqual(event["status"], "success") + self.assertEqual(event["operation"], "session_end") + def test_redacts_secret_fields_before_spool(self): event = normalize_hook_event( { @@ -288,8 +330,11 @@ def test_builds_agent_timeline_payload(self): self.assertEqual(payload["sessionId"], "s") self.assertEqual(payload["agentTurnId"], "s-turn-2") self.assertEqual(payload["timelineId"], "s-turn-2-timeline") + self.assertEqual(payload["metadata"]["sessionId"], "s") + self.assertEqual(payload["metadata"]["sourceClient"], "codex") self.assertEqual(payload["metadata"]["turnId"], "s-turn-2") self.assertEqual(payload["metadata"]["turnSeq"], 2) + self.assertEqual(payload["metadata"]["eventIds"], [event["eventId"]]) self.assertIn("eventId", payload["events"][0]) self.assertEqual(payload["events"][0]["seq"], 1) self.assertEqual(payload["project"]["name"], "project") diff --git a/memind-integrations/codex/tests/test_config.py b/memind-integrations/codex/tests/test_config.py index e0a5afc4..3317ea3d 100644 --- a/memind-integrations/codex/tests/test_config.py +++ b/memind-integrations/codex/tests/test_config.py @@ -26,6 +26,7 @@ def test_defaults_are_codex_specific(self): self.assertEqual(config["agentId"], "codex") self.assertEqual(config["sourceClient"], "codex") self.assertEqual(config["retrieveContextTurns"], 0) + self.assertNotIn("agentIdMode", config) self.assertNotIn("commitOnStop", config) def test_user_config_and_env_override_settings(self): @@ -44,6 +45,7 @@ def test_user_config_and_env_override_settings(self): self.assertEqual(config["agentId"], "custom") self.assertEqual(config["memindApiUrl"], "http://example.test") self.assertEqual(config["retrieveContextTurns"], 2) + self.assertNotIn("agentIdMode", config) self.assertNotIn("commitOnStop", config) self.assertNotIn("ingestionRoles", config) diff --git a/memind-integrations/codex/tests/test_hooks.py b/memind-integrations/codex/tests/test_hooks.py index a86b309f..9db2455d 100644 --- a/memind-integrations/codex/tests/test_hooks.py +++ b/memind-integrations/codex/tests/test_hooks.py @@ -328,6 +328,7 @@ def test_ingest_flushes_agent_timeline_and_clears_events_on_success(self): self.assertEqual(raw_content["metadata"]["turnId"], "s1-turn-1") with SessionStateStore(state_root).locked("s1") as state: self.assertEqual(state.agent_events(), []) + self.assertTrue(state.is_empty()) def test_ingest_spools_agent_timeline_on_partial_success(self): sys.path.insert(0, str(ROOT / "scripts")) diff --git a/memind-integrations/codex/tests/test_identity.py b/memind-integrations/codex/tests/test_identity.py index e8ded291..92e4c473 100644 --- a/memind-integrations/codex/tests/test_identity.py +++ b/memind-integrations/codex/tests/test_identity.py @@ -20,11 +20,17 @@ class IdentityTest(unittest.TestCase): - def test_resolve_identity_uses_codex_project_agent(self): + def test_resolve_identity_always_uses_project_slug(self): with tempfile.TemporaryDirectory() as tmp: - identity = resolve_identity({"agentId": "codex", "agentIdMode": "project", "userId": "u"}, {"cwd": tmp}) + identity = resolve_identity({"agentId": "codex", "userId": "u"}, {"cwd": tmp}) self.assertEqual(identity["userId"], "u") self.assertTrue(identity["agentId"].startswith("codex__")) + self.assertNotEqual(identity["agentId"], "codex") + + def test_agent_id_mode_is_ignored_for_backward_safety(self): + with tempfile.TemporaryDirectory() as tmp: + identity = resolve_identity({"agentId": "codex", "agentIdMode": "global", "userId": "u"}, {"cwd": tmp}) + self.assertTrue(identity["agentId"].startswith("codex__")) def test_project_slug_falls_back_to_path_hash(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/memind-integrations/codex/tests/test_installer.py b/memind-integrations/codex/tests/test_installer.py index 8a246250..b2e6c8be 100644 --- a/memind-integrations/codex/tests/test_installer.py +++ b/memind-integrations/codex/tests/test_installer.py @@ -87,6 +87,18 @@ def test_install_merges_and_reinstall_is_idempotent(self): self.assertIn("echo existing", stop_commands) memind_commands = [command for command in stop_commands if "memind-integrations/codex" in command] self.assertEqual(len(memind_commands), 1) + memind_events = { + event + for event, groups in hooks.items() + for group in groups + for hook in group.get("hooks", []) + if "memind-integrations/codex" in hook.get("command", "") + or "/memind/codex/" in hook.get("command", "") + } + self.assertEqual( + memind_events, + {"SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"}, + ) def test_uninstall_removes_only_memind_entries(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/memind-integrations/codex/tests/test_manifest.py b/memind-integrations/codex/tests/test_manifest.py index acd4b4de..28785a39 100644 --- a/memind-integrations/codex/tests/test_manifest.py +++ b/memind-integrations/codex/tests/test_manifest.py @@ -34,6 +34,10 @@ def test_hooks_json_shape(self): self.assertEqual( set(hooks), {"SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"} ) + self.assertNotIn("PreCompact", hooks) + self.assertNotIn("SessionEnd", hooks) + self.assertNotIn("Notification", hooks) + self.assertNotIn("SubagentStop", hooks) for event in ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"]: self.assertIsInstance(hooks[event], list) self.assertNotIn("matcher", hooks[event][0]) @@ -49,6 +53,7 @@ def test_default_settings_match_spec(self): self.assertEqual(settings["sourceClient"], "codex") self.assertTrue(settings["autoIngestAgentTimeline"]) self.assertEqual(settings["retrieveContextTurns"], 0) + self.assertNotIn("agentIdMode", settings) self.assertNotIn("commitOnStop", settings) self.assertNotIn("autoIngest", settings) self.assertNotIn("ingestionRoles", settings) diff --git a/memind-integrations/codex/tests/test_state.py b/memind-integrations/codex/tests/test_state.py index fb6b4f1e..18d15795 100644 --- a/memind-integrations/codex/tests/test_state.py +++ b/memind-integrations/codex/tests/test_state.py @@ -42,6 +42,17 @@ def test_agent_events_are_deduplicated_and_clear_by_event_id(self): with store.locked("session-1") as state: self.assertEqual(state.agent_events(), [{"eventId": "e2", "seq": 2}]) + def test_state_reports_empty_after_all_events_are_cleared_and_turn_closed(self): + with tempfile.TemporaryDirectory() as tmp: + store = SessionStateStore(Path(tmp)) + session_key = "session-1" + with store.locked(session_key) as state: + turn_id, _turn_seq = state.start_agent_turn(session_key) + state.append_agent_event({"eventId": "e1", "seq": 1}) + state.clear_agent_events(["e1"]) + state.close_agent_turn(turn_id) + self.assertTrue(state.is_empty()) + def test_agent_event_buffer_has_soft_cap(self): with tempfile.TemporaryDirectory() as tmp: store = SessionStateStore(Path(tmp)) @@ -53,6 +64,24 @@ def test_agent_event_buffer_has_soft_cap(self): self.assertEqual(len(events), 500) self.assertEqual(events[0]["eventId"], "e1") self.assertTrue(state.data["agentEventsTruncated"]) + self.assertEqual(state.data["agentEventsDropped"], 1) + + def test_agent_event_buffer_soft_cap_preserves_newest_boundary(self): + with tempfile.TemporaryDirectory() as tmp: + store = SessionStateStore(Path(tmp)) + with store.locked("session-1") as state: + state.append_agent_event({"eventId": "prompt", "seq": 1, "kind": "user_prompt"}) + for index in range(600): + state.append_agent_event( + {"eventId": f"e{index}", "seq": index + 2, "kind": "tool_result"} + ) + state.append_agent_event({"eventId": "stop", "seq": 700, "kind": "stop"}) + with store.locked("session-1") as state: + events = state.agent_events() + self.assertLessEqual(len(events), 500) + self.assertEqual(events[-1]["eventId"], "stop") + self.assertTrue(state.data["agentEventsTruncated"]) + self.assertIn("agentEventsDropped", state.data) def test_cleanup_removes_old_state(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGenerator.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGenerator.java index 6562a318..0db9f6cf 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGenerator.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGenerator.java @@ -52,15 +52,29 @@ private String caption(String content, Map metadata) { } private String summary(Map metadata) { + var parts = new java.util.ArrayList(); String file = first(metadata.get("files")); String command = first(metadata.get("commands")); if (!file.isBlank() && !command.isBlank()) { - return file + "; " + command; + parts.add(file + "; " + command); + } else if (!file.isBlank()) { + parts.add(file); + } else if (!command.isBlank()) { + parts.add(command); } - if (!file.isBlank()) { - return file; + String failureSignal = first(metadata.get("failureSignals")); + if (!failureSignal.isBlank()) { + parts.add(failureSignal); } - return command; + if (contains(metadata.get("eventKinds"), "subagent_stop")) { + parts.add("subagent"); + } + if (contains(metadata.get("eventKinds"), "compact_boundary")) { + parts.add("compact"); + } else if (contains(metadata.get("eventKinds"), "session_end")) { + parts.add("session end"); + } + return String.join("; ", parts); } private String first(Object value) { @@ -74,6 +88,16 @@ private String stringValue(Object value) { return value == null ? "" : value.toString(); } + private boolean contains(Object value, String expected) { + if (!(value instanceof List list)) { + return false; + } + return list.stream() + .filter(java.util.Objects::nonNull) + .map(Object::toString) + .anyMatch(item -> expected.equalsIgnoreCase(item)); + } + private static String truncate(String content, int maxChars) { if (content == null) { return ""; diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java index fc978315..cfc92714 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java @@ -314,6 +314,8 @@ private String phase(AgentEvent event) { } if (event.kind() == AgentEventKind.STOP || event.kind() == AgentEventKind.SESSION_END + || event.kind() == AgentEventKind.COMPACT_BOUNDARY + || event.kind() == AgentEventKind.SYNTHETIC_BOUNDARY || event.kind() == AgentEventKind.TASK_COMPLETED || event.kind() == AgentEventKind.ASSISTANT_MESSAGE) { return "handoff"; @@ -379,6 +381,8 @@ private String boundaryKey(AgentEvent event) { private static boolean isTerminal(AgentEvent event) { return event.kind() == AgentEventKind.STOP || event.kind() == AgentEventKind.SESSION_END + || event.kind() == AgentEventKind.COMPACT_BOUNDARY + || event.kind() == AgentEventKind.SYNTHETIC_BOUNDARY || event.kind() == AgentEventKind.TASK_COMPLETED; } diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java index 403b9cb3..ba400ed6 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java @@ -134,7 +134,8 @@ private List enabledCategories(ParsedSegment segment, ItemExtractionConf allowed, MemoryCategory.PLAYBOOK, options.extractPlaybook() - && eventCount >= options.minEventsForPlaybook() + && (eventCount >= options.minEventsForPlaybook() + || hasHighSignalSubagentEvidence(segment.metadata())) && (!options.requireSuccessForPlaybook() || successfulOutcome(segment.metadata().get("outcome")))); addIfEnabled(categories, allowed, MemoryCategory.DIRECTIVE, options.extractDirective()); @@ -156,6 +157,18 @@ private static boolean successfulOutcome(Object outcome) { return "success".equalsIgnoreCase(value) || "partial_success".equalsIgnoreCase(value); } + private static boolean hasHighSignalSubagentEvidence(Map metadata) { + if (metadata == null) { + return false; + } + return stringList(metadata.get("eventKinds")).stream() + .anyMatch(kind -> "subagent_stop".equalsIgnoreCase(kind)) + || stringList(metadata.get("toolNames")).stream() + .anyMatch(tool -> "task".equalsIgnoreCase(tool)) + || stringList(metadata.get("eventIds")).stream() + .anyMatch(eventId -> eventId.toLowerCase(Locale.ROOT).contains("subagent")); + } + private static List merge( List deterministicEntries, List llmEntries) { diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEventKind.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEventKind.java index 3b15b794..9a6c3738 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEventKind.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEventKind.java @@ -23,16 +23,23 @@ public enum AgentEventKind { USER_PROMPT, ASSISTANT_MESSAGE, + TOOL_START, TOOL_CALL, TOOL_RESULT, + TOOL_FAILURE, COMMAND, FILE_READ, FILE_EDIT, TEST_RESULT, PERMISSION_REQUEST, + SUBAGENT_START, + SUBAGENT_STOP, + NOTIFICATION, ERROR, STOP, SESSION_END, + COMPACT_BOUNDARY, + SYNTHETIC_BOUNDARY, TASK_COMPLETED; @JsonCreator diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGeneratorTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGeneratorTest.java index 581667d5..08e93394 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGeneratorTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGeneratorTest.java @@ -43,4 +43,63 @@ void shouldBuildDeterministicAgentEpisodeCaptionFromMetadata() { "Agent episode: Fix payment tests -> success " + "(src/payment/calc.ts; npm test payment)"); } + + @Test + void shouldIncludeKeyLifecycleAwareEpisodeSignals() { + String caption = + new AgentCaptionGenerator() + .generate( + "content", + Map.of( + "goal", + "Fix payment tests", + "outcome", + "success", + "files", + List.of("src/payment/calc.ts"), + "commands", + List.of("npm test payment"), + "toolNames", + List.of("Task", "Bash"), + "failureSignals", + List.of("payment rounding mismatch"), + "eventKinds", + List.of( + "user_prompt", + "subagent_stop", + "file_edit", + "test_result", + "stop"))) + .block(); + + assertThat(caption) + .contains( + "Fix payment tests", + "success", + "src/payment/calc.ts", + "npm test payment", + "subagent", + "payment rounding mismatch"); + } + + @Test + void shouldMentionCompactBoundaryWhenEpisodeEndsAtCompaction() { + String caption = + new AgentCaptionGenerator() + .generate( + "content", + Map.of( + "goal", + "Continue rawdata-agent implementation", + "outcome", + "success", + "commands", + List.of("mvn test"), + "eventKinds", + List.of("user_prompt", "command", "compact_boundary"))) + .block(); + + assertThat(caption) + .contains("Continue rawdata-agent implementation", "mvn test", "compact"); + } } diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java index 798854e0..26b895e7 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java @@ -319,6 +319,183 @@ void shouldSplitEpisodesWhenTurnMetadataChanges() { assertThat(episodes.get(1).eventIds()).containsExactly("e3"); } + @Test + void shouldCloseEpisodesOnStopAndCompactBoundary() { + List events = + List.of( + AgentEpisodeTestSupport.event( + "e1", + 1, + AgentEventKind.USER_PROMPT, + "2026-05-24T10:00:00Z", + "Review rawdata-agent", + null, + null, + AgentEventStatus.SUCCESS, + null, + null, + null, + null), + AgentEpisodeTestSupport.event( + "e2", + 2, + AgentEventKind.FILE_READ, + "2026-05-24T10:01:00Z", + null, + "Read", + null, + AgentEventStatus.SUCCESS, + "src/main/java/App.java", + "read", + null, + null), + AgentEpisodeTestSupport.event( + "e3", + 3, + AgentEventKind.SUBAGENT_STOP, + "2026-05-24T10:02:00Z", + "Explorer found parser edge cases", + "explorer", + null, + AgentEventStatus.SUCCESS, + null, + null, + null, + null), + AgentEpisodeTestSupport.event( + "e4", + 4, + AgentEventKind.STOP, + "2026-05-24T10:03:00Z", + "done", + null, + null, + AgentEventStatus.SUCCESS, + null, + null, + null, + null), + AgentEpisodeTestSupport.event( + "e5", + 5, + AgentEventKind.USER_PROMPT, + "2026-05-24T10:04:00Z", + "Continue after compaction", + null, + null, + AgentEventStatus.SUCCESS, + null, + null, + null, + null), + AgentEpisodeTestSupport.event( + "e6", + 6, + AgentEventKind.COMMAND, + "2026-05-24T10:05:00Z", + null, + "Bash", + null, + AgentEventStatus.SUCCESS, + null, + "run", + "mvn test", + 0), + AgentEpisodeTestSupport.event( + "e7", + 7, + AgentEventKind.COMPACT_BOUNDARY, + "2026-05-24T10:06:00Z", + "compact", + null, + null, + AgentEventStatus.SUCCESS, + null, + "compact", + null, + null)); + + List episodes = + new AgentEpisodeAssembler() + .assemble(AgentEpisodeTestSupport.paymentTimeline(events)); + + assertThat(episodes).hasSize(2); + assertThat(episodes.get(0).eventIds()).containsExactly("e1", "e2", "e3", "e4"); + assertThat(episodes.get(1).eventIds()).containsExactly("e5", "e6", "e7"); + assertThat(episodes.get(0).phase()).isEqualTo("full"); + assertThat(episodes.get(1).phase()).isEqualTo("full"); + } + + @Test + void shouldClassifyLifecycleAndNotificationEventsConservatively() { + AgentChunkingOptions options = new AgentChunkingOptions(1, 2, 80, Duration.ofMinutes(30)); + List events = + List.of( + AgentEpisodeTestSupport.event( + "e1", + 1, + AgentEventKind.USER_PROMPT, + "2026-05-24T10:00:00Z", + "Investigate flaky tests", + null, + null, + AgentEventStatus.SUCCESS, + null, + null, + null, + null), + AgentEpisodeTestSupport.event( + "e2", + 2, + AgentEventKind.NOTIFICATION, + "2026-05-24T10:01:00Z", + "Permission required for Bash", + null, + null, + AgentEventStatus.FAILED, + null, + "blocked", + null, + null), + AgentEpisodeTestSupport.event( + "e3", + 3, + AgentEventKind.SUBAGENT_STOP, + "2026-05-24T10:02:00Z", + "Explorer checked parser ownership", + "explorer", + null, + AgentEventStatus.SUCCESS, + null, + null, + null, + null), + AgentEpisodeTestSupport.event( + "e4", + 4, + AgentEventKind.SYNTHETIC_BOUNDARY, + "2026-05-24T10:03:00Z", + "flush", + null, + null, + AgentEventStatus.SUCCESS, + null, + "flush", + null, + null)); + + List episodes = + new AgentEpisodeAssembler(options) + .assemble(AgentEpisodeTestSupport.paymentTimeline(events)); + + assertThat(episodes) + .extracting(AgentEpisode::phase) + .containsExactly("investigation", "handoff"); + assertThat(episodes.getFirst().eventIds()).containsExactly("e1", "e2", "e3"); + assertThat(episodes.getFirst().failureSignals()).contains("Permission required for Bash"); + assertThat(episodes.get(1).eventIds()).containsExactly("e4"); + } + private static AgentEvent eventWithMetadata( String id, int seq, AgentEventKind kind, String text, String taskId) { AgentEvent base = diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java index 00903b2b..ca9684bf 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java @@ -170,4 +170,82 @@ void jacksonShouldPreserveAgentTurnAndEventIdFields() throws Exception { assertThat(timeline.agentTurnId()).isEqualTo("turn-1"); assertThat(timeline.events()).extracting(AgentEvent::eventId).containsExactly("event-new"); } + + @Test + void eventKindShouldParseLifecycleWireValues() { + assertThat(AgentEventKind.fromWireValue("notification")) + .isEqualTo(AgentEventKind.NOTIFICATION); + assertThat(AgentEventKind.fromWireValue("subagent_stop")) + .isEqualTo(AgentEventKind.SUBAGENT_STOP); + assertThat(AgentEventKind.fromWireValue("compact_boundary")) + .isEqualTo(AgentEventKind.COMPACT_BOUNDARY); + assertThat(AgentEventKind.fromWireValue("synthetic_boundary")) + .isEqualTo(AgentEventKind.SYNTHETIC_BOUNDARY); + } + + @Test + void contentStringShouldIncludeLifecycleEventEvidence() { + AgentTimelineContent content = + new AgentTimelineContent( + "claude-code", + "1.0", + "session-1", + "turn-1", + "timeline-1", + null, + List.of( + new AgentEvent( + "notice-1", + 1, + AgentEventKind.NOTIFICATION, + Instant.parse("2026-05-24T10:00:00Z"), + "Permission required for Bash", + null, + null, + null, + AgentEventStatus.FAILED, + null, + null, + "blocked", + null, + null, + Map.of()), + new AgentEvent( + "subagent-1", + 2, + AgentEventKind.SUBAGENT_STOP, + Instant.parse("2026-05-24T10:01:00Z"), + "Explorer found parser edge cases", + "explorer", + null, + null, + AgentEventStatus.SUCCESS, + null, + null, + null, + null, + null, + Map.of()), + new AgentEvent( + "compact-1", + 3, + AgentEventKind.COMPACT_BOUNDARY, + Instant.parse("2026-05-24T10:02:00Z"), + "compact", + null, + null, + null, + AgentEventStatus.SUCCESS, + null, + null, + "manual", + null, + null, + Map.of()))); + + assertThat(content.toContentString()) + .contains("notification", "Permission required for Bash") + .contains("subagent_stop", "Explorer found parser edge cases", "tool=explorer") + .contains("compact_boundary", "operation=manual"); + } } diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyLlmTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyLlmTest.java index 62d3889d..c02a994e 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyLlmTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyLlmTest.java @@ -213,6 +213,86 @@ void shouldAllowUserScopeMemoryCategoriesFromAgentTimeline() { }); } + @Test + void shouldAcceptSubagentBackedPlaybookWhenEvidenceBelongsToEpisode() { + var client = + new StubStructuredChatClient( + response( + new MemoryItemExtractionResponse.ExtractedItem( + "When payment test failures are unclear, ask an explorer" + + " subagent to inspect the failing resolver before" + + " editing calc.ts.", + 0.86f, + null, + null, + List.of("playbooks"), + Map.of( + "trigger", + "payment test failures are unclear", + "steps", + List.of( + "Ask explorer subagent to inspect resolver", + "Use the finding before editing calc.ts"), + "expectedOutcome", + "edits are based on the diagnosed resolver issue", + "evidenceEventIds", + List.of("subagent-1")), + "playbook"))); + AgentItemExtractionStrategy strategy = strategy(client); + + List entries = + strategy.extract( + List.of(subagentEpisode()), + DefaultInsightTypes.all(), + agentConfig()) + .block(); + + assertThat(entries) + .filteredOn(entry -> "playbook".equals(entry.category())) + .singleElement() + .satisfies( + entry -> + assertThat(entry.metadata().get("evidenceEventIds")) + .asList() + .containsExactly("subagent-1")); + } + + @Test + void shouldRejectSubagentPlaybookWhenEvidenceIsOutsideEpisode() { + var client = + new StubStructuredChatClient( + response( + new MemoryItemExtractionResponse.ExtractedItem( + "When payment test failures are unclear, ask an explorer" + + " subagent first.", + 0.86f, + null, + null, + List.of("playbooks"), + Map.of( + "trigger", + "payment test failures are unclear", + "steps", + List.of( + "Ask explorer subagent", + "Use the finding before editing"), + "expectedOutcome", + "edits are based on diagnosis", + "evidenceEventIds", + List.of("outside-event")), + "playbook"))); + AgentItemExtractionStrategy strategy = strategy(client); + + List entries = + strategy.extract( + List.of(subagentEpisode()), + DefaultInsightTypes.all(), + agentConfig()) + .block(); + + assertThat(entries).noneMatch(entry -> "playbook".equals(entry.category())); + } + @Test void shouldSkipLlmWhenEpisodeDoesNotMeetMinimumEventThreshold() { var client = @@ -277,6 +357,22 @@ private static ParsedSegment successfulEpisode() { "fileEvents", List.of(fileEvent("e3", 3, "src/payment/calc.ts"))))); } + private static ParsedSegment subagentEpisode() { + return segment( + Map.ofEntries( + Map.entry("segmentType", "agent_episode"), + Map.entry("episodeId", "episode-subagent"), + Map.entry("sourceClient", "claude-code"), + Map.entry("sessionId", "session-123"), + Map.entry("timelineId", "timeline-123"), + Map.entry("outcome", "success"), + Map.entry("files", List.of("src/payment/calc.ts")), + Map.entry("commands", List.of("npm test payment")), + Map.entry("toolNames", List.of("Task")), + Map.entry("failureSignals", List.of()), + Map.entry("eventIds", List.of("prompt-1", "subagent-1", "stop-1")))); + } + private static ParsedSegment shortEpisode() { return segment( Map.ofEntries( diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java index a3a5fff1..f7e79a4c 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java @@ -128,6 +128,104 @@ void shouldIgnoreUnrelatedSuccessfulCommandsForResolutionValidation() { assertThat(entries).noneMatch(entry -> "resolution".equals(entry.category())); } + @Test + void shouldUseLaterMatchingValidationAndIntermediateFileEvidenceForResolution() { + ParsedSegment segment = + segment( + Map.ofEntries( + Map.entry("segmentType", "agent_episode"), + Map.entry("episodeId", "episode-resolution"), + Map.entry("sourceClient", "claude-code"), + Map.entry("sessionId", "session-123"), + Map.entry("timelineId", "timeline-123"), + Map.entry("outcome", "success"), + Map.entry("files", List.of("src/payment/calc.ts")), + Map.entry("commands", List.of("npm test payment")), + Map.entry("toolNames", List.of("Bash", "Edit")), + Map.entry("failureSignals", List.of("payment rounding mismatch")), + Map.entry( + "eventIds", + List.of( + "prompt", + "failed-test", + "early-pass", + "edit-calc", + "passed-test", + "outside-edit")), + Map.entry( + "commandEvents", + List.of( + commandEvent( + "early-pass", + 2, + "npm test payment", + "success", + "passed before failure"), + commandEvent( + "failed-test", + 3, + "npm test payment", + "failed", + "payment rounding mismatch"), + commandEvent( + "passed-test", + 8, + "npm test payment", + "success", + "passed"))), + Map.entry( + "fileEvents", + List.of( + fileEvent("edit-calc", 5, "src/payment/calc.ts"), + fileEvent("outside-edit", 9, "README.md"))))); + + List entries = + strategy.extract(List.of(segment), DefaultInsightTypes.all(), agentConfig()) + .block(); + + assertThat(entries) + .filteredOn(entry -> "resolution".equals(entry.category())) + .singleElement() + .satisfies( + resolution -> { + assertThat(resolution.metadata()) + .containsEntry("validatedBy", "npm test payment"); + assertThat(resolution.metadata().get("evidenceEventIds")) + .asList() + .containsExactly("failed-test", "edit-calc", "passed-test"); + }); + } + + @Test + void shouldNotCreateDeterministicItemFromWeakNotificationOnlyEpisode() { + ParsedSegment segment = + segment( + "Claude is waiting for input.", + Map.of( + "segmentType", + "agent_episode", + "episodeId", + "episode-notification", + "eventIds", + List.of("notice-1"), + "files", + List.of(), + "commands", + List.of(), + "toolNames", + List.of(), + "failureSignals", + List.of(), + "outcome", + "unknown")); + + List entries = + strategy.extract(List.of(segment), DefaultInsightTypes.all(), agentConfig()) + .block(); + + assertThat(entries).isEmpty(); + } + @Test void shouldProduceStableCanonicalContentForDuplicateExtraction() { var first = @@ -213,8 +311,12 @@ private static Map fileEvent(String eventId, int seq, String pat } private static ParsedSegment segment(Map metadata) { + return segment("Goal: Fix payment tests", metadata); + } + + private static ParsedSegment segment(String text, Map metadata) { return new ParsedSegment( - "Goal: Fix payment tests", + text, null, 0, 23, From 3248de53903a3a729bed1e9e135ade24b4d4cbc8 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Tue, 26 May 2026 19:01:10 +0800 Subject: [PATCH 26/54] fix: defer compact extraction until stop --- memind-integrations/claude-code/README.md | 18 +-- .../claude-code/scripts/ingest.py | 12 +- .../claude-code/scripts/pre_compact.py | 22 +++- .../claude-code/tests/test_hooks.py | 61 ++++++++-- .../agent/chunk/AgentEpisodeAssembler.java | 112 +++-------------- .../chunk/AgentEpisodeAssemblerTest.java | 114 ++++++++++++++---- 6 files changed, 189 insertions(+), 150 deletions(-) diff --git a/memind-integrations/claude-code/README.md b/memind-integrations/claude-code/README.md index e98c4d6e..ea295ec8 100644 --- a/memind-integrations/claude-code/README.md +++ b/memind-integrations/claude-code/README.md @@ -20,10 +20,11 @@ The integration is intentionally small: - **Retrieval**: `UserPromptSubmit` calls `MemindClient.memory.retrieve(...)` and injects relevant memories into Claude Code as `...` additional context. -- **Ingestion**: `PreToolUse` and `PostToolUse` buffer normalized tool events locally. `Stop`, `PreCompact`, and - `SessionEnd` flush buffered events as `rawContent.type = "agent_timeline"` through - `AsyncMemindClient.memory.extract(...)`, so Memind can extract user and agent memories from the same agent turn. - `Notification` and `SubagentStop` are buffered as lifecycle evidence when Claude Code emits them. +- **Ingestion**: `PreToolUse` and `PostToolUse` buffer normalized tool events locally. `Stop` flushes the complete + turn as `rawContent.type = "agent_timeline"` through `AsyncMemindClient.memory.extract(...)`, so Memind can extract + user and agent memories from the same agent turn. `PreCompact` only records a local `compact_boundary` checkpoint; + it is submitted with the next `Stop` or `SessionEnd` flush. `Notification` and `SubagentStop` are buffered as + lifecycle evidence when Claude Code emits them. - **Retry**: failed ingestion payloads are spooled under `~/.memind/claude-code/retry/` and replayed on later `SessionStart` hooks. - **Source tagging**: all requests use `sourceClient = "claude-code"` by default, so Memind can distinguish @@ -107,7 +108,7 @@ The installed hooks are: | `PostToolUse` | `scripts/post_tool_use.py` | 5s | Buffer a redacted tool-result event in local session state. | | `Notification` | `scripts/notification.py` | 5s | Buffer permission, blocking, and other user-visible lifecycle notifications. | | `SubagentStop` | `scripts/subagent_stop.py` | 5s | Buffer subagent completion evidence for later playbook and handoff extraction. | -| `PreCompact` | `scripts/pre_compact.py` | 30s | Flush buffered `agent_timeline` events before context compaction. | +| `PreCompact` | `scripts/pre_compact.py` | 30s | Record a local `compact_boundary` checkpoint before context compaction. | | `Stop` | `scripts/ingest.py` | 15s | Flush buffered `agent_timeline` events after a turn. | | `SessionEnd` | `scripts/session_end.py` | 10s | Flush remaining buffered `agent_timeline` events at session end. | @@ -230,9 +231,10 @@ retrieved memories are still formatted from Memind items and insights by the ada ## Ingestion Behavior Ingestion is timeline-only for Claude Code. The plugin does not submit transcript conversation-style raw data. It buffers -one turn timeline under `~/.memind/claude-code/state/`: the submitted user prompt, tool and command events, the -latest assistant message when available from the transcript, and a stop boundary. It flushes the turn through -`AsyncMemindClient.memory.extract(...)` as agent timeline rawdata. A typical timeline payload looks like: +one turn timeline under `~/.memind/claude-code/state/`: the submitted user prompt, tool and command events, optional +compact checkpoints, the latest assistant message when available from the transcript, and a stop boundary. It flushes +the turn through `AsyncMemindClient.memory.extract(...)` as agent timeline rawdata on `Stop`; `SessionEnd` flushes any +remaining buffered events as a cleanup path. A typical timeline payload looks like: ```json { diff --git a/memind-integrations/claude-code/scripts/ingest.py b/memind-integrations/claude-code/scripts/ingest.py index 43ef7b53..1c659c14 100644 --- a/memind-integrations/claude-code/scripts/ingest.py +++ b/memind-integrations/claude-code/scripts/ingest.py @@ -25,7 +25,6 @@ from lib.agent_timeline import ( build_timeline_payload, normalize_assistant_message_event, - normalize_compact_boundary_event, normalize_session_end_event, normalize_stop_event, ) @@ -94,15 +93,6 @@ def _append_boundary_event(state, session_id, hook_input): hook_name = hook_input.get("hook_event_name") or "" if hook_name == "Stop": return _append_stop_events(state, session_id, hook_input) - if hook_name == "PreCompact": - turn_id, turn_seq = state.ensure_agent_turn(session_id) - seq = state.next_agent_seq() - state.append_agent_event( - normalize_compact_boundary_event( - hook_input, seq, turn_id=turn_id, turn_seq=turn_seq - ) - ) - return turn_id if hook_name == "SessionEnd": turn_id, turn_seq = state.ensure_agent_turn(session_id) seq = state.next_agent_seq() @@ -126,7 +116,7 @@ async def ingest_messages_async(config, hook_input): if config.get("autoIngestAgentTimeline", True): hook_input["source_client"] = source_client or "claude-code" submitted_turn_id = _append_boundary_event(state, session_id, hook_input) - agent_events = state.agent_events() + agent_events = state.agent_events() if submitted_turn_id else [] else: agent_events = [] if agent_events: diff --git a/memind-integrations/claude-code/scripts/pre_compact.py b/memind-integrations/claude-code/scripts/pre_compact.py index bacf831d..478926e0 100644 --- a/memind-integrations/claude-code/scripts/pre_compact.py +++ b/memind-integrations/claude-code/scripts/pre_compact.py @@ -19,16 +19,32 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from ingest import ingest_messages +from ingest import state_root +from lib.agent_timeline import normalize_compact_boundary_event from lib.config import load_config from lib.logging_utils import debug_log +from lib.state import SessionStateStore + + +def record_pre_compact(hook_input): + config = load_config() + session_id = hook_input.get("session_id") or "unknown-session" + hook_input["source_client"] = config.get("sourceClient") or "claude-code" + with SessionStateStore(state_root()).locked(session_id) as state: + turn_id, turn_seq = state.ensure_agent_turn(session_id) + seq = state.next_agent_seq() + state.append_agent_event( + normalize_compact_boundary_event( + hook_input, seq, turn_id=turn_id, turn_seq=turn_seq + ) + ) + return {"agentEventsBuffered": 1} def main(): try: hook_input = json.loads(sys.stdin.read() or "{}") - config = load_config() - ingest_messages(config, hook_input) + record_pre_compact(hook_input) except Exception as exc: try: debug_log(load_config(), "pre_compact_failed", {"error": str(exc)}) diff --git a/memind-integrations/claude-code/tests/test_hooks.py b/memind-integrations/claude-code/tests/test_hooks.py index f59db166..7e5f9abf 100644 --- a/memind-integrations/claude-code/tests/test_hooks.py +++ b/memind-integrations/claude-code/tests/test_hooks.py @@ -414,7 +414,44 @@ def test_ingest_flushes_agent_timeline_and_clears_events_on_success(self): self.assertEqual(state.agent_events(), []) self.assertTrue(state.is_empty()) - def test_pre_compact_appends_compact_boundary_before_flush(self): + def test_pre_compact_appends_compact_boundary_without_extraction(self): + sys.path.insert(0, str(ROOT / "scripts")) + import pre_compact + from scripts.lib.state import SessionStateStore + + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + with SessionStateStore(state_dir).locked("s1") as state: + state.append_agent_event( + { + "eventId": "e1", + "seq": 1, + "kind": "user_prompt", + "text": "Continue before compaction", + "metadata": {"turnId": "s1-turn-1", "turnSeq": 1}, + } + ) + with mock.patch.object(pre_compact, "state_root", return_value=state_dir): + with mock.patch.object(pre_compact, "load_config") as load_config: + load_config.return_value = { + "sourceClient": "claude-code", + "memindApiUrl": "http://127.0.0.1:8366", + } + result = pre_compact.record_pre_compact( + { + "hook_event_name": "PreCompact", + "session_id": "s1", + "cwd": tmp, + "timestamp": "2026-05-24T10:04:00Z", + }, + ) + self.assertEqual(result, {"agentEventsBuffered": 1}) + with SessionStateStore(state_dir).locked("s1") as state: + events = state.agent_events() + self.assertEqual([event["kind"] for event in events], ["user_prompt", "compact_boundary"]) + self.assertEqual(events[-1]["metadata"]["turnId"], "s1-turn-1") + + def test_ingest_ignores_pre_compact_as_flush_boundary(self): sys.path.insert(0, str(ROOT / "scripts")) import ingest from scripts.lib.state import SessionStateStore @@ -454,10 +491,10 @@ def test_pre_compact_appends_compact_boundary_before_flush(self): "timestamp": "2026-05-24T10:04:00Z", }, ) - self.assertEqual(result["agentEventsSubmitted"], 2) - client.extract.assert_awaited_once() - raw_content = client.extract.await_args.args[2] - self.assertEqual(raw_content["events"][-1]["kind"], "compact_boundary") + self.assertEqual(result["agentEventsSubmitted"], 0) + client.extract.assert_not_awaited() + with SessionStateStore(state_dir).locked("s1") as state: + self.assertEqual([event["kind"] for event in state.agent_events()], ["user_prompt"]) def test_session_end_appends_session_end_before_flush(self): sys.path.insert(0, str(ROOT / "scripts")) @@ -536,17 +573,25 @@ def test_ingest_spools_agent_timeline_on_partial_success(self): ingest.ingest_messages( config, { + "hook_event_name": "Stop", "session_id": "s1", "cwd": tmp, }, ) payload = json.loads(next(retry_dir.glob("*.json")).read_text()) self.assertEqual(payload["kind"], "extract") - self.assertEqual(payload["eventIds"], ["e1"]) self.assertEqual(payload["rawContent"]["type"], "agent_timeline") - self.assertEqual(payload["rawContent"]["events"][0]["eventId"], "e1") + self.assertEqual( + [event["kind"] for event in payload["rawContent"]["events"]], + ["command", "stop"], + ) + self.assertEqual(payload["eventIds"], [event["eventId"] for event in payload["rawContent"]["events"]]) + self.assertEqual(payload["eventIds"][0], "e1") with SessionStateStore(state_dir).locked("s1") as state: - self.assertEqual(len(state.agent_events()), 1) + self.assertEqual( + [event["kind"] for event in state.agent_events()], + ["command", "stop"], + ) def test_session_start_discards_legacy_conversation_extract_payload(self): sys.path.insert(0, str(ROOT / "scripts")) diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java index cfc92714..cc825fec 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java @@ -14,7 +14,6 @@ package com.openmemind.ai.memory.plugin.rawdata.agent.chunk; import com.openmemind.ai.memory.core.utils.HashUtils; -import com.openmemind.ai.memory.core.utils.TokenUtils; import com.openmemind.ai.memory.plugin.rawdata.agent.config.AgentChunkingOptions; import com.openmemind.ai.memory.plugin.rawdata.agent.content.AgentTimelineContent; import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentCommand; @@ -40,9 +39,6 @@ */ public final class AgentEpisodeAssembler { - private static final List PHASE_ORDER = - List.of("investigation", "implementation", "validation", "handoff"); - private final AgentChunkingOptions options; public AgentEpisodeAssembler() { @@ -57,16 +53,7 @@ public List assemble(AgentTimelineContent timeline) { if (timeline == null || timeline.events().isEmpty()) { return List.of(); } - List baseEpisodes = buildBaseEpisodes(timeline, sorted(timeline.events())); - List result = new ArrayList<>(); - for (AgentEpisode episode : baseEpisodes) { - if (TokenUtils.countTokens(episodeText(episode)) > options.targetEpisodeTokens()) { - result.addAll(splitByPhase(timeline, episode)); - } else { - result.add(episode); - } - } - return List.copyOf(result); + return buildBaseEpisodes(timeline, sorted(timeline.events())); } private List buildBaseEpisodes( @@ -82,8 +69,8 @@ private List buildBaseEpisodes( boolean crossesBoundary = !current.isEmpty() && (startsNewPrompt - || exceedsGap(previous, event) - || exceedsEventLimit(current) + || fallbackBoundaryExceeded( + currentBoundaryKey, previous, event, current) || boundaryKeyChanged(currentBoundaryKey, boundaryKey(event))); if (crossesBoundary) { episodes.add(buildEpisode(timeline, current, "full", Map.of())); @@ -153,47 +140,6 @@ private AgentEpisode buildEpisode( metadata); } - private List splitByPhase(AgentTimelineContent timeline, AgentEpisode episode) { - var byPhase = new LinkedHashMap>(); - PHASE_ORDER.forEach(phase -> byPhase.put(phase, new ArrayList<>())); - for (AgentEvent event : episode.events()) { - byPhase.get(phase(event)).add(event); - } - List split = new ArrayList<>(); - for (String phase : PHASE_ORDER) { - List phaseEvents = byPhase.get(phase); - if (!phaseEvents.isEmpty()) { - split.add(phaseEpisode(timeline, episode, phaseEvents, phase)); - } - } - return List.copyOf(split); - } - - private AgentEpisode phaseEpisode( - AgentTimelineContent timeline, - AgentEpisode parent, - List phaseEvents, - String phase) { - AgentEpisode local = buildEpisode(timeline, phaseEvents, phase, Map.of("phaseSplit", true)); - return new AgentEpisode( - local.id(), - parent.goal(), - parent.outcome(), - local.phase(), - local.events(), - local.eventIds(), - local.files(), - local.fileReferences(), - local.commands(), - local.commandEvents(), - local.toolNames(), - local.toolCalls(), - local.failureSignals(), - local.startTime(), - local.endTime(), - local.metadata()); - } - private String episodeId(AgentTimelineContent timeline, List eventIds) { String firstEventId = eventIds.isEmpty() ? "" : eventIds.getFirst(); String lastEventId = eventIds.isEmpty() ? "" : eventIds.getLast(); @@ -308,43 +254,6 @@ private String goal(List events) { .orElse(""); } - private String phase(AgentEvent event) { - if (event.kind() == AgentEventKind.FILE_EDIT) { - return "implementation"; - } - if (event.kind() == AgentEventKind.STOP - || event.kind() == AgentEventKind.SESSION_END - || event.kind() == AgentEventKind.COMPACT_BOUNDARY - || event.kind() == AgentEventKind.SYNTHETIC_BOUNDARY - || event.kind() == AgentEventKind.TASK_COMPLETED - || event.kind() == AgentEventKind.ASSISTANT_MESSAGE) { - return "handoff"; - } - if ((event.kind() == AgentEventKind.COMMAND || event.kind() == AgentEventKind.TEST_RESULT) - && event.status() == AgentEventStatus.SUCCESS) { - return "validation"; - } - return "investigation"; - } - - private String episodeText(AgentEpisode episode) { - var lines = new ArrayList(); - lines.add(episode.goal()); - lines.addAll(episode.failureSignals()); - lines.addAll(episode.files()); - lines.addAll(episode.commands()); - lines.addAll( - episode.events().stream() - .map( - event -> - String.join( - " ", - normalized(event.text()), - normalized(event.output()))) - .toList()); - return String.join("\n", lines); - } - private boolean exceedsGap(AgentEvent previous, AgentEvent event) { return previous != null && previous.occurredAt() != null @@ -358,6 +267,20 @@ private boolean exceedsEventLimit(List current) { return current.size() >= options.maxEventsPerEpisode(); } + private boolean fallbackBoundaryExceeded( + String currentBoundaryKey, + AgentEvent previous, + AgentEvent event, + List current) { + return !hasText(currentBoundaryKey) + && !hasOpenUserPrompt(current) + && (exceedsGap(previous, event) || exceedsEventLimit(current)); + } + + private boolean hasOpenUserPrompt(List current) { + return current.stream().anyMatch(event -> event.kind() == AgentEventKind.USER_PROMPT); + } + private boolean boundaryKeyChanged(String currentBoundaryKey, String nextBoundaryKey) { return hasText(currentBoundaryKey) && hasText(nextBoundaryKey) @@ -381,7 +304,6 @@ private String boundaryKey(AgentEvent event) { private static boolean isTerminal(AgentEvent event) { return event.kind() == AgentEventKind.STOP || event.kind() == AgentEventKind.SESSION_END - || event.kind() == AgentEventKind.COMPACT_BOUNDARY || event.kind() == AgentEventKind.SYNTHETIC_BOUNDARY || event.kind() == AgentEventKind.TASK_COMPLETED; } diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java index 26b895e7..726de771 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java @@ -194,7 +194,7 @@ void shouldClosePreviousEpisodeWhenNewUserPromptAppears() { } @Test - void shouldSplitEpisodesOnThirtyOneMinuteGap() { + void shouldKeepExplicitTurnTogetherAcrossLongGap() { List events = List.of( AgentEpisodeTestSupport.event( @@ -235,6 +235,58 @@ void shouldSplitEpisodesOnThirtyOneMinuteGap() { null, null, "npm test payment", + 0), + AgentEpisodeTestSupport.event( + "e4", + 4, + AgentEventKind.STOP, + "2026-05-24T10:33:00Z", + "done", + null, + null, + AgentEventStatus.SUCCESS, + null, + null, + null, + null)); + + List episodes = + new AgentEpisodeAssembler() + .assemble(AgentEpisodeTestSupport.paymentTimeline(events)); + + assertThat(episodes).hasSize(1); + assertThat(episodes.getFirst().eventIds()).containsExactly("e1", "e2", "e3", "e4"); + } + + @Test + void shouldSplitFallbackEpisodesOnLongGapWhenNoPromptBoundaryExists() { + List events = + List.of( + AgentEpisodeTestSupport.event( + "e1", + 1, + AgentEventKind.COMMAND, + "2026-05-24T10:01:00Z", + null, + "Bash", + "rounding mismatch", + AgentEventStatus.FAILED, + null, + null, + "npm test payment", + 1), + AgentEpisodeTestSupport.event( + "e2", + 2, + AgentEventKind.COMMAND, + "2026-05-24T10:32:01Z", + null, + "Bash", + "passed", + AgentEventStatus.SUCCESS, + null, + null, + "npm test payment", 0)); List episodes = @@ -242,12 +294,12 @@ void shouldSplitEpisodesOnThirtyOneMinuteGap() { .assemble(AgentEpisodeTestSupport.paymentTimeline(events)); assertThat(episodes).hasSize(2); - assertThat(episodes.getFirst().eventIds()).containsExactly("e1", "e2"); - assertThat(episodes.get(1).eventIds()).containsExactly("e3"); + assertThat(episodes.getFirst().eventIds()).containsExactly("e1"); + assertThat(episodes.get(1).eventIds()).containsExactly("e2"); } @Test - void shouldSplitEpisodesWhenEventCountExceedsMax() { + void shouldNotSplitExplicitTurnWhenEventCountExceedsMax() { AgentChunkingOptions options = new AgentChunkingOptions(2_000, 4_000, 2, Duration.ofMinutes(30)); List events = AgentEpisodeTestSupport.paymentEvents(); @@ -256,14 +308,12 @@ void shouldSplitEpisodesWhenEventCountExceedsMax() { new AgentEpisodeAssembler(options) .assemble(AgentEpisodeTestSupport.paymentTimeline(events)); - assertThat(episodes).hasSize(3); - assertThat(episodes) - .extracting(AgentEpisode::eventIds) - .containsExactly(List.of("e1", "e2"), List.of("e3", "e4"), List.of("e5")); + assertThat(episodes).hasSize(1); + assertThat(episodes.getFirst().eventIds()).containsExactly("e1", "e2", "e3", "e4", "e5"); } @Test - void shouldSplitOversizedEpisodeIntoPhases() { + void shouldNotSplitExplicitTurnWhenEpisodeExceedsTargetTokens() { AgentChunkingOptions options = new AgentChunkingOptions(20, 40, 80, Duration.ofMinutes(30)); List episodes = @@ -272,15 +322,10 @@ void shouldSplitOversizedEpisodeIntoPhases() { AgentEpisodeTestSupport.paymentTimeline( AgentEpisodeTestSupport.paymentEvents())); - assertThat(episodes) - .extracting(AgentEpisode::phase) - .containsExactly("investigation", "implementation", "validation", "handoff"); - assertThat(episodes) - .allSatisfy(episode -> assertThat(episode.goal()).isEqualTo("Fix payment tests")); - assertThat(episodes) - .allSatisfy( - episode -> - assertThat(episode.metadata()).containsEntry("phaseSplit", true)); + assertThat(episodes).hasSize(1); + assertThat(episodes.getFirst().phase()).isEqualTo("full"); + assertThat(episodes.getFirst().goal()).isEqualTo("Fix payment tests"); + assertThat(episodes.getFirst().metadata()).doesNotContainKey("phaseSplit"); } @Test @@ -320,7 +365,7 @@ void shouldSplitEpisodesWhenTurnMetadataChanges() { } @Test - void shouldCloseEpisodesOnStopAndCompactBoundary() { + void shouldCloseEpisodesOnStopButKeepCompactBoundaryInsideTurn() { List events = List.of( AgentEpisodeTestSupport.event( @@ -427,7 +472,28 @@ void shouldCloseEpisodesOnStopAndCompactBoundary() { } @Test - void shouldClassifyLifecycleAndNotificationEventsConservatively() { + void shouldKeepSameTurnEventsTogetherAcrossCompactBoundaryUntilStop() { + List events = + List.of( + eventWithTurnMetadata( + "e1", 1, AgentEventKind.USER_PROMPT, "Fix payment tests", "turn-a"), + eventWithTurnMetadata("e2", 2, AgentEventKind.COMMAND, null, "turn-a"), + eventWithTurnMetadata( + "e3", 3, AgentEventKind.COMPACT_BOUNDARY, "compact", "turn-a"), + eventWithTurnMetadata("e4", 4, AgentEventKind.COMMAND, null, "turn-a"), + eventWithTurnMetadata("e5", 5, AgentEventKind.STOP, "done", "turn-a")); + + List episodes = + new AgentEpisodeAssembler() + .assemble(AgentEpisodeTestSupport.paymentTimeline(events)); + + assertThat(episodes).hasSize(1); + assertThat(episodes.getFirst().eventIds()).containsExactly("e1", "e2", "e3", "e4", "e5"); + assertThat(episodes.getFirst().outcome()).isEqualTo(AgentOutcome.SUCCESS); + } + + @Test + void shouldKeepLifecycleAndNotificationEventsInFullTurn() { AgentChunkingOptions options = new AgentChunkingOptions(1, 2, 80, Duration.ofMinutes(30)); List events = List.of( @@ -488,12 +554,10 @@ void shouldClassifyLifecycleAndNotificationEventsConservatively() { new AgentEpisodeAssembler(options) .assemble(AgentEpisodeTestSupport.paymentTimeline(events)); - assertThat(episodes) - .extracting(AgentEpisode::phase) - .containsExactly("investigation", "handoff"); - assertThat(episodes.getFirst().eventIds()).containsExactly("e1", "e2", "e3"); + assertThat(episodes).hasSize(1); + assertThat(episodes.getFirst().phase()).isEqualTo("full"); + assertThat(episodes.getFirst().eventIds()).containsExactly("e1", "e2", "e3", "e4"); assertThat(episodes.getFirst().failureSignals()).contains("Permission required for Bash"); - assertThat(episodes.get(1).eventIds()).containsExactly("e4"); } private static AgentEvent eventWithMetadata( From 09dd9bca45abfb7a19ac6f57606968f926d14b1e Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Wed, 27 May 2026 11:14:07 +0800 Subject: [PATCH 27/54] Unify coding agent memory identity --- memind-integrations/claude-code/README.md | 44 ++++++++++--------- .../claude-code/scripts/lib/agent_timeline.py | 13 +++++- .../claude-code/scripts/lib/config.py | 2 +- .../claude-code/scripts/lib/identity.py | 4 +- memind-integrations/claude-code/settings.json | 2 +- .../claude-code/tests/test_agent_timeline.py | 3 ++ .../claude-code/tests/test_config.py | 1 + .../claude-code/tests/test_hooks.py | 9 ++-- .../claude-code/tests/test_identity.py | 13 +++--- memind-integrations/codex/README.md | 30 +++++++------ .../codex/scripts/lib/agent_timeline.py | 13 +++++- .../codex/scripts/lib/config.py | 2 +- .../codex/scripts/lib/identity.py | 4 +- memind-integrations/codex/settings.json | 2 +- .../codex/tests/test_agent_timeline.py | 3 ++ .../codex/tests/test_config.py | 2 +- memind-integrations/codex/tests/test_hooks.py | 9 ++-- .../codex/tests/test_identity.py | 13 +++--- .../codex/tests/test_manifest.py | 2 +- .../agent/chunk/AgentSegmentFormatter.java | 13 ++++++ .../item/AgentItemExtractionStrategy.java | 5 +++ .../agent/item/AgentMemoryItemFactory.java | 5 +++ .../agent/chunk/AgentEpisodeTestSupport.java | 6 ++- .../chunk/AgentSegmentFormatterTest.java | 2 + .../AgentItemExtractionStrategyLlmTest.java | 8 +++- .../item/AgentItemExtractionStrategyTest.java | 7 +++ 26 files changed, 142 insertions(+), 75 deletions(-) diff --git a/memind-integrations/claude-code/README.md b/memind-integrations/claude-code/README.md index ea295ec8..24f3ce54 100644 --- a/memind-integrations/claude-code/README.md +++ b/memind-integrations/claude-code/README.md @@ -126,7 +126,7 @@ User configuration is optional. Save overrides as `~/.memind/claude-code.json`: "memindApiUrl": "http://127.0.0.1:8366", "memindApiToken": null, "userId": "local__alice", - "agentId": "claude-code", + "agentId": "coding-agent", "sourceClient": "claude-code", "autoIngestAgentTimeline": true, "retrieveContextTurns": 0 @@ -147,7 +147,7 @@ Settings are loaded in this order: | `memindApiUrl` | `http://127.0.0.1:8366` | Memind server URL. | | `memindApiToken` | `null` | Optional bearer token. | | `userId` | `local__` | Memind user identity. | -| `agentId` | `claude-code` | Base agent identity. A stable project suffix is always appended before calling Memind. | +| `agentId` | `coding-agent` | Shared Memind agent identity. Use the same value from Claude Code, Codex, and API clients to share one coding-agent memory space. | | `sourceClient` | `claude-code` | Source marker stored with Memind data. | | `autoRetrieve` | `true` | Enables prompt-time memory retrieval. | | `autoIngestAgentTimeline` | `true` | Enables user prompt, tool/result, assistant message, and stop event buffering plus `agent_timeline` rawdata flush. | @@ -166,7 +166,7 @@ Supported settings can be overridden with environment variables: export MEMIND_API_URL=http://127.0.0.1:8366 export MEMIND_API_TOKEN=... export MEMIND_USER_ID=local__alice -export MEMIND_AGENT_ID=claude-code +export MEMIND_AGENT_ID=coding-agent export MEMIND_SOURCE_CLIENT=claude-code export MEMIND_AUTO_INGEST_AGENT_TIMELINE=true export MEMIND_RETRIEVE_CONTEXT_TURNS=0 @@ -183,15 +183,15 @@ Additional environment variables include `MEMIND_AUTO_RETRIEVE`, `MEMIND_RETRIEV By default, Memind stores Claude Code memory under: - `userId`: `local__` -- `agentId`: `claude-code__-` +- `agentId`: `coding-agent` -The project hash is based on the Git remote URL when available, otherwise the local project path. This keeps -different repositories separated while allowing memory to survive moving between Claude Code sessions. +Claude Code, Codex, and direct Memind API clients can share memory by using the same `userId` and `agentId`. +`sourceClient` records where a memory came from; it is not an isolation boundary. -`agentId` in configuration is the base identity only. The runtime always appends the project suffix before -retrieval or ingestion, so this integration does not provide a global, all-project Claude Code memory mode. -`sessionId`, `agentTurnId`, `timelineId`, and per-event turn metadata are stored only inside raw content and -item metadata; they do not create Memind core project or session entities. +Project information is stored as rawdata and item metadata, including a stable `projectSlug` based on the Git +remote URL when available, otherwise the local project path. Project metadata supports ranking, diagnostics, and +future context compilation without creating separate Memind core project or session entities. `sessionId`, +`agentTurnId`, `timelineId`, and per-event turn metadata are also stored only inside raw content and item metadata. ## Retrieval Behavior @@ -239,7 +239,7 @@ remaining buffered events as a cleanup path. A typical timeline payload looks li ```json { "userId": "local__alice", - "agentId": "claude-code__project_hash", + "agentId": "coding-agent", "sourceClient": "claude-code", "rawContent": { "type": "agent_timeline", @@ -247,7 +247,11 @@ remaining buffered events as a cleanup path. A typical timeline payload looks li "sessionId": "session-123", "agentTurnId": "session-123-turn-1", "timelineId": "session-123-turn-1-timeline", - "project": {"name": "payment-service", "rootPath": "/repo/payment-service"}, + "project": { + "name": "payment-service", + "rootPath": "/repo/payment-service", + "metadata": {"projectSlug": "payment-service-"} + }, "events": [ { "eventId": "event-id", @@ -332,7 +336,7 @@ claude plugin validate memind-integrations/claude-code ### End-to-End Smoke Test -For a deterministic first test, use a fixed base Memind identity. If you already have +For a deterministic first test, use a fixed Memind identity. If you already have `~/.memind/claude-code.json`, merge these fields instead of replacing the file: ```bash @@ -340,7 +344,7 @@ mkdir -p ~/.memind cat > ~/.memind/claude-code.json <<'JSON' { "userId": "local__memind-smoke", - "agentId": "claude-code-smoke", + "agentId": "coding-agent-smoke", "sourceClient": "claude-code", "debug": true } @@ -364,16 +368,16 @@ curl -fsSL -X POST http://127.0.0.1:8366/open/v1/memory/retrieve \ -H 'Content-Type: application/json' \ -d '{ "userId": "local__memind-smoke", - "agentId": "claude-code-smoke__", + "agentId": "coding-agent-smoke", "query": "blue-lake-42", "strategy": "SIMPLE", "trace": false }' ``` -Replace `` with the stable suffix shown in debug logs or Memind item metadata. The response should -include matching `items` or `insights` under `data`. If agent timelines exist but `items` and `insights` are empty, -the server-side `rawdata-agent` extractor did not produce retrievable memory entries yet. +The response should include matching `items` or `insights` under `data`. Project metadata such as `projectSlug` +is stored on the rawdata/item metadata, not in `agentId`. If agent timelines exist but `items` and `insights` are +empty, the server-side `rawdata-agent` extractor did not produce retrievable memory entries yet. For local debugging, enable logs: @@ -455,7 +459,7 @@ curl -fsSL http://127.0.0.1:8366/open/v1/health ``` - Confirm `autoRetrieve` is `true`. -- Confirm existing memories are stored under the same `userId` and resolved project-scoped `agentId`. +- Confirm existing memories are stored under the same `userId` and `agentId`. - Try setting `retrieveContextTurns` to `1` or `2` if the current prompt is very short. ### Agent timeline events are not ingested @@ -469,7 +473,7 @@ curl -fsSL http://127.0.0.1:8366/open/v1/health The default reliable mode submits agent timelines through `AsyncMemindClient.memory.extract(...)`, so a `SUCCESS` response means extraction finished for that timeline payload. If retrieval still does not surface the expected -memory, confirm the same `userId` and resolved project-scoped `agentId` are used for ingestion and retrieval, then inspect +memory, confirm the same `userId` and `agentId` are used for ingestion and retrieval, then inspect `~/.memind/claude-code.log` with `MEMIND_DEBUG=true`. ## Limitations diff --git a/memind-integrations/claude-code/scripts/lib/agent_timeline.py b/memind-integrations/claude-code/scripts/lib/agent_timeline.py index 3002be15..f7d9bcde 100644 --- a/memind-integrations/claude-code/scripts/lib/agent_timeline.py +++ b/memind-integrations/claude-code/scripts/lib/agent_timeline.py @@ -17,6 +17,11 @@ import re from pathlib import Path +try: + from .identity import project_slug +except ImportError: + from lib.identity import project_slug + MAX_TEXT_CHARS = 4000 NORMALIZATION_VERSION = 1 @@ -565,7 +570,13 @@ def build_timeline_payload(config, identity, session_id, events, hook_input): payload["metadata"]["turnSeq"] = turn_seq if cwd: path = Path(cwd) - payload["project"] = {"name": path.name, "rootPath": str(path)} + slug = project_slug(path) + payload["project"] = { + "name": path.name, + "rootPath": str(path), + "metadata": {"projectSlug": slug}, + } + payload["metadata"]["projectSlug"] = slug return payload diff --git a/memind-integrations/claude-code/scripts/lib/config.py b/memind-integrations/claude-code/scripts/lib/config.py index fceade2a..42aa9fb2 100644 --- a/memind-integrations/claude-code/scripts/lib/config.py +++ b/memind-integrations/claude-code/scripts/lib/config.py @@ -20,7 +20,7 @@ "memindApiUrl": "http://127.0.0.1:8366", "memindApiToken": None, "userId": None, - "agentId": "claude-code", + "agentId": "coding-agent", "sourceClient": "claude-code", "autoRetrieve": True, "autoIngestAgentTimeline": True, diff --git a/memind-integrations/claude-code/scripts/lib/identity.py b/memind-integrations/claude-code/scripts/lib/identity.py index 4eac8ef6..8ac610a5 100644 --- a/memind-integrations/claude-code/scripts/lib/identity.py +++ b/memind-integrations/claude-code/scripts/lib/identity.py @@ -50,8 +50,6 @@ def project_slug(cwd): def resolve_identity(config, hook_input): - cwd = hook_input.get("cwd") or os.getcwd() user_id = config.get("userId") or f"local{SEPARATOR}{getpass.getuser()}" - base_agent = config.get("agentId") or "claude-code" - agent_id = f"{base_agent}{SEPARATOR}{project_slug(cwd)}" + agent_id = config.get("agentId") or "coding-agent" return {"userId": user_id, "agentId": agent_id} diff --git a/memind-integrations/claude-code/settings.json b/memind-integrations/claude-code/settings.json index 1ab8fce5..7abc29df 100644 --- a/memind-integrations/claude-code/settings.json +++ b/memind-integrations/claude-code/settings.json @@ -2,7 +2,7 @@ "memindApiUrl": "http://127.0.0.1:8366", "memindApiToken": null, "userId": null, - "agentId": "claude-code", + "agentId": "coding-agent", "sourceClient": "claude-code", "autoRetrieve": true, "autoIngestAgentTimeline": true, diff --git a/memind-integrations/claude-code/tests/test_agent_timeline.py b/memind-integrations/claude-code/tests/test_agent_timeline.py index b7124731..759a384d 100644 --- a/memind-integrations/claude-code/tests/test_agent_timeline.py +++ b/memind-integrations/claude-code/tests/test_agent_timeline.py @@ -372,6 +372,9 @@ def test_builds_agent_timeline_payload(self): self.assertEqual(payload["events"][0]["seq"], 1) self.assertEqual(payload["project"]["name"], "project") self.assertEqual(payload["project"]["rootPath"], "/tmp/project") + project_slug = payload["project"]["metadata"]["projectSlug"] + self.assertRegex(project_slug, r"^project-[a-f0-9]{12}$") + self.assertEqual(payload["metadata"]["projectSlug"], project_slug) if __name__ == "__main__": diff --git a/memind-integrations/claude-code/tests/test_config.py b/memind-integrations/claude-code/tests/test_config.py index 01819bc1..774e0f9e 100644 --- a/memind-integrations/claude-code/tests/test_config.py +++ b/memind-integrations/claude-code/tests/test_config.py @@ -41,6 +41,7 @@ def test_parse_list(self): self.assertEqual(parse_list(" user , assistant ,,"), ["user", "assistant"]) def test_defaults_match_spec(self): + self.assertEqual(DEFAULT_SETTINGS["agentId"], "coding-agent") self.assertEqual(DEFAULT_SETTINGS["retrieveContextTurns"], 0) self.assertEqual(DEFAULT_SETTINGS["sourceClient"], "claude-code") self.assertTrue(DEFAULT_SETTINGS["autoIngestAgentTimeline"]) diff --git a/memind-integrations/claude-code/tests/test_hooks.py b/memind-integrations/claude-code/tests/test_hooks.py index 7e5f9abf..fd902b32 100644 --- a/memind-integrations/claude-code/tests/test_hooks.py +++ b/memind-integrations/claude-code/tests/test_hooks.py @@ -308,8 +308,7 @@ def test_ingest_ignores_transcript_when_no_agent_events(self): "autoIngestAgentTimeline": True, "ingestRetrySpool": True, "sourceClient": "claude-code", - "agentId": "claude-code", - "agentIdMode": "global", + "agentId": "coding-agent", "userId": "u", } with tempfile.TemporaryDirectory() as tmp: @@ -346,8 +345,7 @@ def test_ingest_flushes_agent_timeline_and_clears_events_on_success(self): "autoIngestAgentTimeline": True, "ingestRetrySpool": True, "sourceClient": "claude-code", - "agentId": "claude-code", - "agentIdMode": "global", + "agentId": "coding-agent", "userId": "u", } with tempfile.TemporaryDirectory() as tmp: @@ -552,8 +550,7 @@ def test_ingest_spools_agent_timeline_on_partial_success(self): "autoIngestAgentTimeline": True, "ingestRetrySpool": True, "sourceClient": "claude-code", - "agentId": "claude-code", - "agentIdMode": "global", + "agentId": "coding-agent", "userId": "u", } with tempfile.TemporaryDirectory() as tmp: diff --git a/memind-integrations/claude-code/tests/test_identity.py b/memind-integrations/claude-code/tests/test_identity.py index a40c6f91..780b8689 100644 --- a/memind-integrations/claude-code/tests/test_identity.py +++ b/memind-integrations/claude-code/tests/test_identity.py @@ -20,19 +20,18 @@ class IdentityTest(unittest.TestCase): - def test_resolve_identity_always_uses_project_slug(self): + def test_resolve_identity_uses_fixed_agent_id(self): with tempfile.TemporaryDirectory() as tmp: - identity = resolve_identity({"agentId": "claude-code"}, {"cwd": tmp}) + identity = resolve_identity({"agentId": "coding-agent"}, {"cwd": tmp}) self.assertTrue(identity["userId"].startswith("local__")) - self.assertTrue(identity["agentId"].startswith("claude-code__")) - self.assertNotEqual(identity["agentId"], "claude-code") + self.assertEqual(identity["agentId"], "coding-agent") self.assertNotIn(":", identity["userId"]) self.assertNotIn(":", identity["agentId"]) - def test_agent_id_mode_is_ignored_for_backward_safety(self): + def test_resolve_identity_defaults_to_shared_coding_agent(self): with tempfile.TemporaryDirectory() as tmp: - identity = resolve_identity({"agentId": "claude-code", "agentIdMode": "global"}, {"cwd": tmp}) - self.assertTrue(identity["agentId"].startswith("claude-code__")) + identity = resolve_identity({}, {"cwd": tmp}) + self.assertEqual(identity["agentId"], "coding-agent") def test_project_slug_has_stable_suffix(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/memind-integrations/codex/README.md b/memind-integrations/codex/README.md index 702bd6fa..10962f39 100644 --- a/memind-integrations/codex/README.md +++ b/memind-integrations/codex/README.md @@ -141,7 +141,7 @@ User configuration is optional. Save overrides as `~/.memind/codex.json`: "memindApiUrl": "http://127.0.0.1:8366", "memindApiToken": null, "userId": "local__alice", - "agentId": "codex", + "agentId": "coding-agent", "sourceClient": "codex", "autoIngestAgentTimeline": true, "retrieveContextTurns": 0 @@ -161,7 +161,7 @@ Settings are loaded in this order: | `memindApiUrl` | `http://127.0.0.1:8366` | Memind server URL. | | `memindApiToken` | `null` | Optional bearer token. | | `userId` | `local__` | Memind user identity. | -| `agentId` | `codex` | Base agent identity. A stable project suffix is always appended before calling Memind. | +| `agentId` | `coding-agent` | Shared Memind agent identity. Use the same value from Claude Code, Codex, and API clients to share one coding-agent memory space. | | `sourceClient` | `codex` | Source marker stored with Memind data. | | `autoRetrieve` | `true` | Enables prompt-time memory retrieval. | | `autoIngestAgentTimeline` | `true` | Enables user prompt, tool/result, assistant message, and stop event buffering plus `agent_timeline` rawdata flush. | @@ -180,7 +180,7 @@ Every common setting can be overridden with an environment variable: export MEMIND_API_URL=http://127.0.0.1:8366 export MEMIND_API_TOKEN=... export MEMIND_USER_ID=local__alice -export MEMIND_AGENT_ID=codex +export MEMIND_AGENT_ID=coding-agent export MEMIND_SOURCE_CLIENT=codex export MEMIND_AUTO_INGEST_AGENT_TIMELINE=true export MEMIND_RETRIEVE_CONTEXT_TURNS=0 @@ -197,15 +197,15 @@ Additional environment variables include `MEMIND_AUTO_RETRIEVE`, By default, Memind stores Codex memory under: - `userId`: `local__` -- `agentId`: `codex__-` +- `agentId`: `coding-agent` -The project hash is based on the Git remote URL when available, otherwise the local project path. This keeps -different repositories separated while allowing memory to survive moving between Codex sessions. +Claude Code, Codex, and direct Memind API clients can share memory by using the same `userId` and `agentId`. +`sourceClient` records where a memory came from; it is not an isolation boundary. -`agentId` in configuration is the base identity only. The runtime always appends the project suffix before -retrieval or ingestion, so this integration does not provide a global, all-project Codex memory mode. -`sessionId`, `agentTurnId`, `timelineId`, and per-event turn metadata are stored only inside raw content and -item metadata; they do not create Memind core project or session entities. +Project information is stored as rawdata and item metadata, including a stable `projectSlug` based on the Git +remote URL when available, otherwise the local project path. Project metadata supports ranking, diagnostics, and +future context compilation without creating separate Memind core project or session entities. `sessionId`, +`agentTurnId`, `timelineId`, and per-event turn metadata are also stored only inside raw content and item metadata. ## Retrieval Behavior @@ -252,7 +252,7 @@ assistant message when available from the transcript, and a stop boundary. It fl ```json { "userId": "local__alice", - "agentId": "codex__project_hash", + "agentId": "coding-agent", "sourceClient": "codex", "rawContent": { "type": "agent_timeline", @@ -260,7 +260,11 @@ assistant message when available from the transcript, and a stop boundary. It fl "sessionId": "session-123", "agentTurnId": "session-123-turn-1", "timelineId": "session-123-turn-1-timeline", - "project": {"name": "payment-service", "rootPath": "/repo/payment-service"}, + "project": { + "name": "payment-service", + "rootPath": "/repo/payment-service", + "metadata": {"projectSlug": "payment-service-"} + }, "events": [ { "eventId": "event-id", @@ -419,7 +423,7 @@ curl -fsSL http://127.0.0.1:8366/open/v1/health ``` - Confirm `autoRetrieve` is `true`. -- Confirm existing memories are stored under the same `userId` and resolved project-scoped `agentId`. +- Confirm existing memories are stored under the same `userId` and `agentId`. - Try setting `retrieveContextTurns` to `1` or `2` if the current prompt is very short. ### Agent timeline events are not ingested diff --git a/memind-integrations/codex/scripts/lib/agent_timeline.py b/memind-integrations/codex/scripts/lib/agent_timeline.py index ad0e8ea2..df40f22e 100644 --- a/memind-integrations/codex/scripts/lib/agent_timeline.py +++ b/memind-integrations/codex/scripts/lib/agent_timeline.py @@ -17,6 +17,11 @@ import re from pathlib import Path +try: + from .identity import project_slug +except ImportError: + from lib.identity import project_slug + MAX_TEXT_CHARS = 4000 NORMALIZATION_VERSION = 1 @@ -511,7 +516,13 @@ def build_timeline_payload(config, identity, session_id, events, hook_input): payload["metadata"]["turnSeq"] = turn_seq if cwd: path = Path(cwd) - payload["project"] = {"name": path.name, "rootPath": str(path)} + slug = project_slug(path) + payload["project"] = { + "name": path.name, + "rootPath": str(path), + "metadata": {"projectSlug": slug}, + } + payload["metadata"]["projectSlug"] = slug return payload diff --git a/memind-integrations/codex/scripts/lib/config.py b/memind-integrations/codex/scripts/lib/config.py index d9b17a39..14228b0f 100644 --- a/memind-integrations/codex/scripts/lib/config.py +++ b/memind-integrations/codex/scripts/lib/config.py @@ -20,7 +20,7 @@ "memindApiUrl": "http://127.0.0.1:8366", "memindApiToken": None, "userId": None, - "agentId": "codex", + "agentId": "coding-agent", "sourceClient": "codex", "autoRetrieve": True, "autoIngestAgentTimeline": True, diff --git a/memind-integrations/codex/scripts/lib/identity.py b/memind-integrations/codex/scripts/lib/identity.py index 7b3b4a52..8ac610a5 100644 --- a/memind-integrations/codex/scripts/lib/identity.py +++ b/memind-integrations/codex/scripts/lib/identity.py @@ -50,8 +50,6 @@ def project_slug(cwd): def resolve_identity(config, hook_input): - cwd = hook_input.get("cwd") or os.getcwd() user_id = config.get("userId") or f"local{SEPARATOR}{getpass.getuser()}" - base_agent = config.get("agentId") or "codex" - agent_id = f"{base_agent}{SEPARATOR}{project_slug(cwd)}" + agent_id = config.get("agentId") or "coding-agent" return {"userId": user_id, "agentId": agent_id} diff --git a/memind-integrations/codex/settings.json b/memind-integrations/codex/settings.json index 38481728..09fba74d 100644 --- a/memind-integrations/codex/settings.json +++ b/memind-integrations/codex/settings.json @@ -2,7 +2,7 @@ "memindApiUrl": "http://127.0.0.1:8366", "memindApiToken": null, "userId": null, - "agentId": "codex", + "agentId": "coding-agent", "sourceClient": "codex", "autoRetrieve": true, "autoIngestAgentTimeline": true, diff --git a/memind-integrations/codex/tests/test_agent_timeline.py b/memind-integrations/codex/tests/test_agent_timeline.py index a299af4e..a7c290dd 100644 --- a/memind-integrations/codex/tests/test_agent_timeline.py +++ b/memind-integrations/codex/tests/test_agent_timeline.py @@ -339,6 +339,9 @@ def test_builds_agent_timeline_payload(self): self.assertEqual(payload["events"][0]["seq"], 1) self.assertEqual(payload["project"]["name"], "project") self.assertEqual(payload["project"]["rootPath"], "/tmp/project") + project_slug = payload["project"]["metadata"]["projectSlug"] + self.assertRegex(project_slug, r"^project-[a-f0-9]{12}$") + self.assertEqual(payload["metadata"]["projectSlug"], project_slug) if __name__ == "__main__": diff --git a/memind-integrations/codex/tests/test_config.py b/memind-integrations/codex/tests/test_config.py index 3317ea3d..93ae882d 100644 --- a/memind-integrations/codex/tests/test_config.py +++ b/memind-integrations/codex/tests/test_config.py @@ -23,7 +23,7 @@ class ConfigTest(unittest.TestCase): def test_defaults_are_codex_specific(self): config = load_config(plugin_root=Path(__file__).resolve().parents[1], user_config_path="/missing", env={}) - self.assertEqual(config["agentId"], "codex") + self.assertEqual(config["agentId"], "coding-agent") self.assertEqual(config["sourceClient"], "codex") self.assertEqual(config["retrieveContextTurns"], 0) self.assertNotIn("agentIdMode", config) diff --git a/memind-integrations/codex/tests/test_hooks.py b/memind-integrations/codex/tests/test_hooks.py index 9db2455d..97a747d0 100644 --- a/memind-integrations/codex/tests/test_hooks.py +++ b/memind-integrations/codex/tests/test_hooks.py @@ -227,8 +227,7 @@ def test_ingest_ignores_transcript_when_no_agent_events(self): "autoIngestAgentTimeline": True, "ingestRetrySpool": False, "sourceClient": "codex", - "agentId": "codex", - "agentIdMode": "global", + "agentId": "coding-agent", "userId": "u", } with tempfile.TemporaryDirectory() as tmp: @@ -259,8 +258,7 @@ def test_ingest_flushes_agent_timeline_and_clears_events_on_success(self): "autoIngestAgentTimeline": True, "ingestRetrySpool": True, "sourceClient": "codex", - "agentId": "codex", - "agentIdMode": "global", + "agentId": "coding-agent", "userId": "u", } with tempfile.TemporaryDirectory() as tmp: @@ -341,8 +339,7 @@ def test_ingest_spools_agent_timeline_on_partial_success(self): "autoIngestAgentTimeline": True, "ingestRetrySpool": True, "sourceClient": "codex", - "agentId": "codex", - "agentIdMode": "global", + "agentId": "coding-agent", "userId": "u", } with tempfile.TemporaryDirectory() as tmp: diff --git a/memind-integrations/codex/tests/test_identity.py b/memind-integrations/codex/tests/test_identity.py index 92e4c473..969ddb5d 100644 --- a/memind-integrations/codex/tests/test_identity.py +++ b/memind-integrations/codex/tests/test_identity.py @@ -20,17 +20,16 @@ class IdentityTest(unittest.TestCase): - def test_resolve_identity_always_uses_project_slug(self): + def test_resolve_identity_uses_fixed_agent_id(self): with tempfile.TemporaryDirectory() as tmp: - identity = resolve_identity({"agentId": "codex", "userId": "u"}, {"cwd": tmp}) + identity = resolve_identity({"agentId": "coding-agent", "userId": "u"}, {"cwd": tmp}) self.assertEqual(identity["userId"], "u") - self.assertTrue(identity["agentId"].startswith("codex__")) - self.assertNotEqual(identity["agentId"], "codex") + self.assertEqual(identity["agentId"], "coding-agent") - def test_agent_id_mode_is_ignored_for_backward_safety(self): + def test_resolve_identity_defaults_to_shared_coding_agent(self): with tempfile.TemporaryDirectory() as tmp: - identity = resolve_identity({"agentId": "codex", "agentIdMode": "global", "userId": "u"}, {"cwd": tmp}) - self.assertTrue(identity["agentId"].startswith("codex__")) + identity = resolve_identity({"userId": "u"}, {"cwd": tmp}) + self.assertEqual(identity["agentId"], "coding-agent") def test_project_slug_falls_back_to_path_hash(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/memind-integrations/codex/tests/test_manifest.py b/memind-integrations/codex/tests/test_manifest.py index 28785a39..6fab9099 100644 --- a/memind-integrations/codex/tests/test_manifest.py +++ b/memind-integrations/codex/tests/test_manifest.py @@ -49,7 +49,7 @@ def test_hooks_json_shape(self): def test_default_settings_match_spec(self): settings = json.loads((ROOT / "settings.json").read_text()) - self.assertEqual(settings["agentId"], "codex") + self.assertEqual(settings["agentId"], "coding-agent") self.assertEqual(settings["sourceClient"], "codex") self.assertTrue(settings["autoIngestAgentTimeline"]) self.assertEqual(settings["retrieveContextTurns"], 0) diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java index 8c1384af..ea4fb543 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java @@ -79,6 +79,11 @@ private Map metadata(AgentTimelineContent timeline, AgentEpisode if (hasText(project.name())) { metadata.put("projectName", project.name()); } + String projectSlug = string(project.metadata().get("projectSlug")); + if (hasText(projectSlug)) { + metadata.put("projectSlug", projectSlug); + metadata.put("projectId", projectSlug); + } if (hasText(project.rootPath())) { metadata.put( "projectRootHash", "sha256:" + HashUtils.sampledSha256(project.rootPath())); @@ -233,6 +238,14 @@ private static boolean hasText(String value) { return value != null && !value.isBlank(); } + private static String string(Object value) { + if (value == null) { + return null; + } + String text = value.toString().trim(); + return text.isEmpty() ? null : text; + } + public record FormattedSegment(String content, Map metadata) { public FormattedSegment { diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java index ba400ed6..5b004e38 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java @@ -270,6 +270,11 @@ private static Map metadata( copy(segment.metadata(), metadata, "sessionId"); copy(segment.metadata(), metadata, "timelineId"); copy(segment.metadata(), metadata, "sourceClient"); + copy(segment.metadata(), metadata, "projectId"); + copy(segment.metadata(), metadata, "projectSlug"); + copy(segment.metadata(), metadata, "projectName"); + copy(segment.metadata(), metadata, "projectRootHash"); + copy(segment.metadata(), metadata, "gitBranch"); copy(segment.metadata(), metadata, "outcome"); copy(segment.metadata(), metadata, "files"); copy(segment.metadata(), metadata, "commands"); diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java index f2301c96..b5c75bc7 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java @@ -141,6 +141,11 @@ private Map baseMetadata(EpisodeMetadata episode) { copy(episode.raw(), metadata, "sessionId"); copy(episode.raw(), metadata, "timelineId"); copy(episode.raw(), metadata, "sourceClient"); + copy(episode.raw(), metadata, "projectId"); + copy(episode.raw(), metadata, "projectSlug"); + copy(episode.raw(), metadata, "projectName"); + copy(episode.raw(), metadata, "projectRootHash"); + copy(episode.raw(), metadata, "gitBranch"); metadata.put("files", episode.files()); metadata.put("commands", episode.commands()); metadata.put("toolNames", episode.toolNames()); diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeTestSupport.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeTestSupport.java index aad8ba04..620e5b95 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeTestSupport.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeTestSupport.java @@ -33,7 +33,11 @@ static AgentTimelineContent paymentTimeline(List events) { "session-123", "session-123-agent-turn-1-5", "timeline-123", - new AgentProject("payments-api", "/Users/alice/work/payments-api", null, Map.of()), + new AgentProject( + "payments-api", + "/Users/alice/work/payments-api", + null, + Map.of("projectSlug", "payments-api-remote")), events); } diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java index 7e2154e6..8f816f7d 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java @@ -50,6 +50,8 @@ void shouldFormatEpisodeTextAndMetadataDeterministically() { .containsEntry("sessionId", "session-123") .containsEntry("timelineId", "timeline-123") .containsEntry("projectName", "payments-api") + .containsEntry("projectId", "payments-api-remote") + .containsEntry("projectSlug", "payments-api-remote") .containsEntry("outcome", "success"); assertThat(formatted.metadata().get("files")) .asList() diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyLlmTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyLlmTest.java index c02a994e..f1ebeafb 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyLlmTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyLlmTest.java @@ -96,7 +96,10 @@ void shouldMergeValidLlmPlaybookWithDeterministicMetadataAndGraphHints() { .containsEntry("episodeId", "episode-123") .containsEntry("sessionId", "session-123") .containsEntry("timelineId", "timeline-123") - .containsEntry("sourceClient", "codex"); + .containsEntry("sourceClient", "codex") + .containsEntry("projectId", "payments-api-remote") + .containsEntry("projectSlug", "payments-api-remote") + .containsEntry("projectName", "payments-api"); assertThat(entry.metadata().get("evidenceEventIds")) .asList() .containsExactly("e3", "e4", "e5"); @@ -336,6 +339,9 @@ private static ParsedSegment successfulEpisode() { Map.entry("sourceClient", "codex"), Map.entry("sessionId", "session-123"), Map.entry("timelineId", "timeline-123"), + Map.entry("projectId", "payments-api-remote"), + Map.entry("projectSlug", "payments-api-remote"), + Map.entry("projectName", "payments-api"), Map.entry("outcome", "success"), Map.entry("files", List.of("src/payment/calc.ts")), Map.entry("commands", List.of("npm test payment")), diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java index f7e79a4c..38bb6dd2 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java @@ -50,6 +50,10 @@ void shouldExtractDeterministicToolAndResolutionFromSuccessfulEpisode() { assertThat(entry.type()).isEqualTo(MemoryItemType.FACT); assertThat(entry.insightTypes()).containsExactly("tools"); assertThat(entry.metadata()).containsEntry("episodeId", "episode-123"); + assertThat(entry.metadata()) + .containsEntry("projectId", "payments-api-remote") + .containsEntry("projectSlug", "payments-api-remote") + .containsEntry("projectName", "payments-api"); assertThat(entry.metadata()) .containsEntry("command", "npm test payment"); assertThat(entry.metadata()).containsEntry("successCount", 1); @@ -253,6 +257,9 @@ private static ParsedSegment successfulEpisode() { Map.entry("sourceClient", "codex"), Map.entry("sessionId", "session-123"), Map.entry("timelineId", "timeline-123"), + Map.entry("projectId", "payments-api-remote"), + Map.entry("projectSlug", "payments-api-remote"), + Map.entry("projectName", "payments-api"), Map.entry("outcome", "success"), Map.entry("files", List.of("src/payment/calc.ts")), Map.entry("commands", List.of("npm test payment")), From 92bf30c8b1b2335fe484fd31f97c444e2500a8f3 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Wed, 27 May 2026 17:50:21 +0800 Subject: [PATCH 28/54] feat: add agent memory query APIs and clients --- memind-clients/go/http.go | 12 + memind-clients/go/memory.go | 30 ++ memind-clients/go/memory_test.go | 54 ++- memind-clients/go/models.go | 131 +++++- memind-clients/go/validation.go | 25 ++ memind-clients/go/validation_test.go | 16 + .../openmemind/ai/client/MemindClient.java | 30 ++ .../client/model/request/MetadataFilter.java | 25 ++ .../request/QueryMemoryItemsRequest.java | 123 ++++++ .../request/QueryMemoryRawDataRequest.java | 117 +++++ .../model/request/RetrieveMemoryRequest.java | 64 ++- .../response/QueryMemoryItemsResponse.java | 38 ++ .../response/QueryMemoryRawDataResponse.java | 35 ++ .../response/RetrieveMemoryResponse.java | 11 +- .../ai/client/MemindClientTest.java | 92 +++- memind-clients/python/src/memind/__init__.py | 22 + .../src/memind/resources/async_memory.py | 37 ++ .../python/src/memind/resources/memory.py | 37 ++ .../python/src/memind/types/__init__.py | 22 + .../python/src/memind/types/memory.py | 101 +++++ .../python/tests/test_async_client.py | 42 +- memind-clients/python/tests/test_client.py | 107 ++++- memind-clients/python/tests/test_models.py | 127 +++++- .../python/tests/test_public_api.py | 9 + memind-clients/rust/src/lib.rs | 9 +- memind-clients/rust/src/models/memory.rs | 418 ++++++++++++++++++ memind-clients/rust/src/models/mod.rs | 7 +- memind-clients/rust/src/resources/memory.rs | 52 ++- memind-clients/rust/tests/client_test.rs | 39 +- memind-clients/rust/tests/public_api_test.rs | 16 +- .../rust/tests/serialization_test.rs | 51 ++- .../typescript/src/core/validate.ts | 82 ++++ memind-clients/typescript/src/index.ts | 11 + .../typescript/src/resources/memory.ts | 36 ++ memind-clients/typescript/src/types/memory.ts | 101 +++++ .../typescript/tests/client.test.ts | 117 +++++ .../typescript/tests/public-api.test.ts | 20 +- .../typescript/tests/validation.test.ts | 68 ++- .../core/retrieval/ItemRetrievalGuard.java | 4 + .../core/retrieval/RetrievalResult.java | 24 +- .../core/retrieval/filter/MetadataFilter.java | 38 ++ .../filter/MetadataFilterMatcher.java | 87 ++++ .../core/retrieval/query/QueryContext.java | 11 + .../retrieval/scoring/RawDataAggregator.java | 51 ++- .../retrieval/tier/ItemTierRetriever.java | 7 +- .../retrieval/ItemRetrievalGuardTest.java | 42 ++ .../agent/caption/AgentCaptionGenerator.java | 202 ++++++++- .../agent/plugin/AgentRawDataPlugin.java | 2 +- .../caption/AgentCaptionGeneratorTest.java | 122 +++++ ...gentExtractionPipelineIntegrationTest.java | 42 +- .../openapi/OpenMemoryQueryController.java | 22 +- .../domain/item/query/ItemPageQuery.java | 45 ++ .../domain/memory/request/MetadataFilter.java | 47 ++ .../request/QueryMemoryItemsRequest.java | 45 ++ .../request/QueryMemoryRawDataRequest.java | 57 +++ .../memory/request/RetrieveMemoryRequest.java | 26 +- .../response/QueryMemoryItemsResponse.java | 44 ++ .../response/QueryMemoryRawDataResponse.java | 42 ++ .../response/RetrieveMemoryResponse.java | 22 +- .../rawdata/query/RawDataPageQuery.java | 31 ++ .../mapper/item/AdminItemQueryMapper.java | 20 + .../rawdata/AdminRawDataQueryMapper.java | 6 + .../memory/OpenMemoryApplicationService.java | 100 ++++- .../memory/OpenMemoryAssetQueryService.java | 129 ++++++ .../openapi/OpenMemoryControllerTest.java | 133 +++++- .../service/item/ItemQueryServiceTest.java | 121 ++++- .../OpenMemoryApplicationServiceTest.java | 58 ++- 67 files changed, 3854 insertions(+), 60 deletions(-) create mode 100644 memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/request/MetadataFilter.java create mode 100644 memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/request/QueryMemoryItemsRequest.java create mode 100644 memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/request/QueryMemoryRawDataRequest.java create mode 100644 memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/response/QueryMemoryItemsResponse.java create mode 100644 memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/response/QueryMemoryRawDataResponse.java create mode 100644 memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/filter/MetadataFilter.java create mode 100644 memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/filter/MetadataFilterMatcher.java create mode 100644 memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/request/MetadataFilter.java create mode 100644 memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/request/QueryMemoryItemsRequest.java create mode 100644 memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/request/QueryMemoryRawDataRequest.java create mode 100644 memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/response/QueryMemoryItemsResponse.java create mode 100644 memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/response/QueryMemoryRawDataResponse.java create mode 100644 memind-server/src/main/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryAssetQueryService.java diff --git a/memind-clients/go/http.go b/memind-clients/go/http.go index aebd1c21..77c274bc 100644 --- a/memind-clients/go/http.go +++ b/memind-clients/go/http.go @@ -224,6 +224,14 @@ func normalizeResponse(out any) { if response.Trace != nil && response.Trace.Stages == nil { response.Trace.Stages = []StageView{} } + case *QueryMemoryItemsResponse: + if response.Items == nil { + response.Items = []MemoryItem{} + } + case *QueryMemoryRawDataResponse: + if response.RawData == nil { + response.RawData = []MemoryRawData{} + } } } @@ -261,6 +269,10 @@ func requiredArrayFields(out any) []string { return []string{"rawDataIds", "itemIds", "insightIds"} case *RetrieveMemoryResponse: return []string{"items", "insights", "rawData", "evidences"} + case *QueryMemoryItemsResponse: + return []string{"items"} + case *QueryMemoryRawDataResponse: + return []string{"rawData"} default: return nil } diff --git a/memind-clients/go/memory.go b/memind-clients/go/memory.go index 486ed32b..4bea0540 100644 --- a/memind-clients/go/memory.go +++ b/memind-clients/go/memory.go @@ -95,6 +95,36 @@ func (s *MemoryService) Retrieve(ctx context.Context, req RetrieveMemoryRequest, return &out, nil } +func (s *MemoryService) QueryItems(ctx context.Context, req QueryMemoryItemsRequest, opts ...RequestOption) (*QueryMemoryItemsResponse, error) { + if err := validateQueryItemsRequest(req); err != nil { + return nil, err + } + cfg, err := applyRequestOptions(opts) + if err != nil { + return nil, err + } + var out QueryMemoryItemsResponse + if err := s.client.do(ctx, http.MethodPost, "/memory/items/query", req, &out, cfg); err != nil { + return nil, err + } + return &out, nil +} + +func (s *MemoryService) QueryRawData(ctx context.Context, req QueryMemoryRawDataRequest, opts ...RequestOption) (*QueryMemoryRawDataResponse, error) { + if err := validateQueryRawDataRequest(req); err != nil { + return nil, err + } + cfg, err := applyRequestOptions(opts) + if err != nil { + return nil, err + } + var out QueryMemoryRawDataResponse + if err := s.client.do(ctx, http.MethodPost, "/memory/raw-data/query", req, &out, cfg); err != nil { + return nil, err + } + return &out, nil +} + func (s *MemoryService) EnqueueExtract(ctx context.Context, req ExtractMemoryRequest, opts ...RequestOption) (*OperationAccepted, error) { if err := validateExtractRequest(req); err != nil { return nil, err diff --git a/memind-clients/go/memory_test.go b/memind-clients/go/memory_test.go index e1a98370..8764aa60 100644 --- a/memind-clients/go/memory_test.go +++ b/memind-clients/go/memory_test.go @@ -51,6 +51,8 @@ func TestMemoryMethodsCallEndpoints(t *testing.T) { "POST /open/v1/memory/sync/add-message", "POST /open/v1/memory/sync/commit", "POST /open/v1/memory/retrieve", + "POST /open/v1/memory/items/query", + "POST /open/v1/memory/raw-data/query", "POST /open/v1/memory/async/extract", "POST /open/v1/memory/async/add-message", "POST /open/v1/memory/async/commit", @@ -64,6 +66,10 @@ func TestMemoryMethodsCallEndpoints(t *testing.T) { _, _ = w.Write([]byte(`{"data":{"triggered":false}}`)) case "/open/v1/memory/retrieve": _, _ = w.Write([]byte(`{"data":{"items":[],"insights":[],"rawData":[],"evidences":[]}}`)) + case "/open/v1/memory/items/query": + _, _ = w.Write([]byte(`{"data":{"items":[{"id":"101","text":"Run targeted tests.","rawDataType":"agent_timeline","sourceClient":"claude-code","metadata":{"project":"memind"}}],"nextCursor":"101"}}`)) + case "/open/v1/memory/raw-data/query": + _, _ = w.Write([]byte(`{"data":{"rawData":[{"id":"rd-1","type":"agent_timeline","sourceClient":"codex","caption":"Fixed retry test.","metadata":{"sessionId":"s1"},"segment":{"events":[]}}]}}`)) case "/open/v1/memory/async/extract", "/open/v1/memory/async/add-message", "/open/v1/memory/async/commit": w.WriteHeader(http.StatusAccepted) _, _ = w.Write([]byte(`{"data":{"operationId":"op_1","status":"accepted","mode":"async"}}`)) @@ -81,16 +87,50 @@ func TestMemoryMethodsCallEndpoints(t *testing.T) { extractReq := ExtractMemoryRequest{UserID: "u", AgentID: "a", RawContent: Conversation(UserMessage("hello"))} addReq := AddMessageRequest{UserID: "u", AgentID: "a", Message: UserMessage("hello")} commitReq := CommitMemoryRequest{UserID: "u", AgentID: "a"} - retrieveReq := RetrieveMemoryRequest{UserID: "u", AgentID: "a", Query: "q", Strategy: StrategySimple} + retrieveReq := RetrieveMemoryRequest{ + UserID: "u", + AgentID: "a", + Query: "q", + Strategy: StrategySimple, + Scope: "ALL", + Categories: []string{"playbook"}, + TimeRange: &TimeRange{Field: "occurredAt", From: timePtr(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))}, + MetadataFilter: &MetadataFilter{All: []MetadataCondition{ + {Path: "project", Op: "eq", Value: "memind"}, + }}, + Include: &RetrieveIncludeOptions{RawDataMetadata: boolPtr(true)}, + } _, _ = client.Memory.Extract(ctx, extractReq) _, _ = client.Memory.AddMessage(ctx, addReq) _, _ = client.Memory.Commit(ctx, commitReq) _, _ = client.Memory.Retrieve(ctx, retrieveReq) + items, _ := client.Memory.QueryItems(ctx, QueryMemoryItemsRequest{ + UserID: "u", + AgentID: "a", + Categories: []string{"playbook"}, + SourceClients: []string{"claude-code"}, + RawDataTypes: []string{"agent_timeline"}, + Limit: intPtr(10), + }) + rawData, _ := client.Memory.QueryRawData(ctx, QueryMemoryRawDataRequest{ + UserID: "u", + AgentID: "a", + Types: []string{"agent_timeline"}, + SourceClients: []string{"codex"}, + Include: &RawDataQueryIncludeOptions{Segment: boolPtr(true)}, + }) _, _ = client.Memory.EnqueueExtract(ctx, extractReq) _, _ = client.Memory.EnqueueAddMessage(ctx, addReq) _, _ = client.Memory.EnqueueCommit(ctx, commitReq) + if items.NextCursor != "101" || len(items.Items) != 1 || items.Items[0].RawDataType != "agent_timeline" { + t.Fatalf("items response = %#v", items) + } + if len(rawData.RawData) != 1 || rawData.RawData[0].Segment == nil { + t.Fatalf("rawData response = %#v", rawData) + } + if len(seen) != len(expected) { t.Fatalf("seen = %#v", seen) } @@ -101,6 +141,18 @@ func TestMemoryMethodsCallEndpoints(t *testing.T) { } } +func boolPtr(value bool) *bool { + return &value +} + +func intPtr(value int) *int { + return &value +} + +func timePtr(value time.Time) *time.Time { + return &value +} + func TestMutatingMethodsDoNotRetryByDefault(t *testing.T) { var attempts int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/memind-clients/go/models.go b/memind-clients/go/models.go index 7e596539..30408ec3 100644 --- a/memind-clients/go/models.go +++ b/memind-clients/go/models.go @@ -59,11 +59,69 @@ type CommitMemoryRequest struct { } type RetrieveMemoryRequest struct { - UserID string `json:"userId"` - AgentID string `json:"agentId"` - Query string `json:"query"` - Strategy Strategy `json:"strategy"` - Trace *bool `json:"trace,omitempty"` + UserID string `json:"userId"` + AgentID string `json:"agentId"` + Query string `json:"query"` + Strategy Strategy `json:"strategy"` + Trace *bool `json:"trace,omitempty"` + Scope string `json:"scope,omitempty"` + Categories []string `json:"categories,omitempty"` + TimeRange *TimeRange `json:"timeRange,omitempty"` + MetadataFilter *MetadataFilter `json:"metadataFilter,omitempty"` + Include *RetrieveIncludeOptions `json:"include,omitempty"` +} + +type TimeRange struct { + Field string `json:"field,omitempty"` + From *time.Time `json:"from,omitempty"` + To *time.Time `json:"to,omitempty"` +} + +type MetadataCondition struct { + Path string `json:"path"` + Op string `json:"op"` + Value any `json:"value,omitempty"` +} + +type MetadataFilter struct { + All []MetadataCondition `json:"all,omitempty"` + Any []MetadataCondition `json:"any,omitempty"` + Not []MetadataCondition `json:"not,omitempty"` +} + +type RetrieveIncludeOptions struct { + RawDataMetadata *bool `json:"rawDataMetadata,omitempty"` + RawDataSegment *bool `json:"rawDataSegment,omitempty"` +} + +type RawDataQueryIncludeOptions struct { + Segment *bool `json:"segment,omitempty"` + Metadata *bool `json:"metadata,omitempty"` +} + +type QueryMemoryItemsRequest struct { + UserID string `json:"userId"` + AgentID string `json:"agentId"` + Scope string `json:"scope,omitempty"` + Categories []string `json:"categories,omitempty"` + SourceClients []string `json:"sourceClients,omitempty"` + RawDataTypes []string `json:"rawDataTypes,omitempty"` + TimeRange *TimeRange `json:"timeRange,omitempty"` + MetadataFilter *MetadataFilter `json:"metadataFilter,omitempty"` + Limit *int `json:"limit,omitempty"` + Cursor string `json:"cursor,omitempty"` +} + +type QueryMemoryRawDataRequest struct { + UserID string `json:"userId"` + AgentID string `json:"agentId"` + Types []string `json:"types,omitempty"` + SourceClients []string `json:"sourceClients,omitempty"` + TimeRange *TimeRange `json:"timeRange,omitempty"` + MetadataFilter *MetadataFilter `json:"metadataFilter,omitempty"` + Include *RawDataQueryIncludeOptions `json:"include,omitempty"` + Limit *int `json:"limit,omitempty"` + Cursor string `json:"cursor,omitempty"` } type HealthResponse struct { @@ -104,11 +162,13 @@ type RetrieveMemoryResponse struct { } type RetrievedItem struct { - ID string `json:"id"` - Text string `json:"text"` - VectorScore float32 `json:"vectorScore"` - FinalScore float64 `json:"finalScore"` - OccurredAt *time.Time `json:"occurredAt,omitempty"` + ID string `json:"id"` + Text string `json:"text"` + VectorScore float32 `json:"vectorScore"` + FinalScore float64 `json:"finalScore"` + OccurredAt *time.Time `json:"occurredAt,omitempty"` + Category string `json:"category,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` } type RetrievedInsight struct { @@ -118,10 +178,53 @@ type RetrievedInsight struct { } type RetrievedRawData struct { - RawDataID string `json:"rawDataId"` - Caption string `json:"caption,omitempty"` - MaxScore float64 `json:"maxScore"` - ItemIDs []string `json:"itemIds,omitempty"` + RawDataID string `json:"rawDataId"` + Caption string `json:"caption,omitempty"` + MaxScore float64 `json:"maxScore"` + ItemIDs []string `json:"itemIds,omitempty"` + Type string `json:"type,omitempty"` + SourceClient string `json:"sourceClient,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + StartTime *time.Time `json:"startTime,omitempty"` + EndTime *time.Time `json:"endTime,omitempty"` + CreatedAt *time.Time `json:"createdAt,omitempty"` +} + +type QueryMemoryItemsResponse struct { + Items []MemoryItem `json:"items"` + NextCursor string `json:"nextCursor,omitempty"` +} + +type MemoryItem struct { + ID string `json:"id"` + Text string `json:"text"` + Scope string `json:"scope,omitempty"` + Category string `json:"category,omitempty"` + Type string `json:"type,omitempty"` + RawDataID string `json:"rawDataId,omitempty"` + RawDataType string `json:"rawDataType,omitempty"` + SourceClient string `json:"sourceClient,omitempty"` + OccurredAt *time.Time `json:"occurredAt,omitempty"` + ObservedAt *time.Time `json:"observedAt,omitempty"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type QueryMemoryRawDataResponse struct { + RawData []MemoryRawData `json:"rawData"` + NextCursor string `json:"nextCursor,omitempty"` +} + +type MemoryRawData struct { + ID string `json:"id"` + Type string `json:"type,omitempty"` + SourceClient string `json:"sourceClient,omitempty"` + Caption string `json:"caption,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Segment map[string]any `json:"segment,omitempty"` + StartTime *time.Time `json:"startTime,omitempty"` + EndTime *time.Time `json:"endTime,omitempty"` + CreatedAt *time.Time `json:"createdAt,omitempty"` } type RetrievalTraceView struct { diff --git a/memind-clients/go/validation.go b/memind-clients/go/validation.go index 5ebebc9a..19c2c5d3 100644 --- a/memind-clients/go/validation.go +++ b/memind-clients/go/validation.go @@ -62,6 +62,22 @@ func validateRetrieveRequest(req RetrieveMemoryRequest) error { return validationErrorOrNil(issues) } +func validateQueryItemsRequest(req QueryMemoryItemsRequest) error { + var issues []ValidationIssue + requireNonBlank(&issues, "userId", req.UserID) + requireNonBlank(&issues, "agentId", req.AgentID) + validateLimit(&issues, "limit", req.Limit) + return validationErrorOrNil(issues) +} + +func validateQueryRawDataRequest(req QueryMemoryRawDataRequest) error { + var issues []ValidationIssue + requireNonBlank(&issues, "userId", req.UserID) + requireNonBlank(&issues, "agentId", req.AgentID) + validateLimit(&issues, "limit", req.Limit) + return validationErrorOrNil(issues) +} + func validateRawContent(raw RawContent) error { payload, err := raw.MarshalJSON() if err != nil { @@ -233,6 +249,15 @@ func requireNonBlank(issues *[]ValidationIssue, field, value string) { } } +func validateLimit(issues *[]ValidationIssue, field string, value *int) { + if value == nil { + return + } + if *value < 1 || *value > 100 { + *issues = append(*issues, ValidationIssue{Field: field, Message: field + " must be between 1 and 100"}) + } +} + func validationErrorOrNil(issues []ValidationIssue) error { if len(issues) == 0 { return nil diff --git a/memind-clients/go/validation_test.go b/memind-clients/go/validation_test.go index ac47729e..907c01a5 100644 --- a/memind-clients/go/validation_test.go +++ b/memind-clients/go/validation_test.go @@ -57,3 +57,19 @@ func TestValidateExtractAcceptsConversation(t *testing.T) { t.Fatalf("validateExtractRequest conversation error = %v", err) } } + +func TestValidateStructuredQueryRequests(t *testing.T) { + if err := validateQueryItemsRequest(QueryMemoryItemsRequest{UserID: "u", AgentID: "a", Limit: intPtr(100)}); err != nil { + t.Fatalf("validateQueryItemsRequest valid error = %v", err) + } + if err := validateQueryRawDataRequest(QueryMemoryRawDataRequest{UserID: "u", AgentID: "a", Limit: intPtr(100)}); err != nil { + t.Fatalf("validateQueryRawDataRequest valid error = %v", err) + } + + if err := validateQueryItemsRequest(QueryMemoryItemsRequest{UserID: "u", AgentID: "a", Limit: intPtr(0)}); err == nil { + t.Fatal("validateQueryItemsRequest limit 0 error = nil, want error") + } + if err := validateQueryRawDataRequest(QueryMemoryRawDataRequest{UserID: "u", AgentID: "a", Limit: intPtr(101)}); err == nil { + t.Fatal("validateQueryRawDataRequest limit 101 error = nil, want error") + } +} diff --git a/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/MemindClient.java b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/MemindClient.java index 7bc1d055..3ff394d9 100644 --- a/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/MemindClient.java +++ b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/MemindClient.java @@ -20,10 +20,14 @@ import com.openmemind.ai.client.model.request.AddMessageRequest; import com.openmemind.ai.client.model.request.CommitMemoryRequest; import com.openmemind.ai.client.model.request.ExtractMemoryRequest; +import com.openmemind.ai.client.model.request.QueryMemoryItemsRequest; +import com.openmemind.ai.client.model.request.QueryMemoryRawDataRequest; import com.openmemind.ai.client.model.request.RetrieveMemoryRequest; import com.openmemind.ai.client.model.response.AddMessageResponse; import com.openmemind.ai.client.model.response.ExtractMemoryResponse; import com.openmemind.ai.client.model.response.HealthResponse; +import com.openmemind.ai.client.model.response.QueryMemoryItemsResponse; +import com.openmemind.ai.client.model.response.QueryMemoryRawDataResponse; import com.openmemind.ai.client.model.response.RetrieveMemoryResponse; import java.time.Duration; import java.util.Objects; @@ -64,6 +68,14 @@ public RetrieveMemoryResponse retrieve(RetrieveMemoryRequest request) { return joinAndUnwrap(retrieveAsync(request)); } + public QueryMemoryItemsResponse queryItems(QueryMemoryItemsRequest request) { + return joinAndUnwrap(queryItemsAsync(request)); + } + + public QueryMemoryRawDataResponse queryRawData(QueryMemoryRawDataRequest request) { + return joinAndUnwrap(queryRawDataAsync(request)); + } + public HealthResponse health() { return joinAndUnwrap(healthAsync()); } @@ -104,6 +116,24 @@ public CompletableFuture retrieveAsync(RetrieveMemoryReq new TypeReference>() {}); } + public CompletableFuture queryItemsAsync( + QueryMemoryItemsRequest request) { + ensureOpen(); + return httpClient.post( + "/open/v1/memory/items/query", + Objects.requireNonNull(request, "request"), + new TypeReference>() {}); + } + + public CompletableFuture queryRawDataAsync( + QueryMemoryRawDataRequest request) { + ensureOpen(); + return httpClient.post( + "/open/v1/memory/raw-data/query", + Objects.requireNonNull(request, "request"), + new TypeReference>() {}); + } + public CompletableFuture healthAsync() { ensureOpen(); return httpClient.get("/open/v1/health", new TypeReference>() {}); diff --git a/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/request/MetadataFilter.java b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/request/MetadataFilter.java new file mode 100644 index 00000000..9c92d8e6 --- /dev/null +++ b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/request/MetadataFilter.java @@ -0,0 +1,25 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.client.model.request; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record MetadataFilter( + List all, List any, @JsonProperty("not") List not) { + + public record Condition(String path, String op, Object value) {} +} diff --git a/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/request/QueryMemoryItemsRequest.java b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/request/QueryMemoryItemsRequest.java new file mode 100644 index 00000000..ffd3fd71 --- /dev/null +++ b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/request/QueryMemoryItemsRequest.java @@ -0,0 +1,123 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.client.model.request; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record QueryMemoryItemsRequest( + String userId, + String agentId, + String scope, + List categories, + List sourceClients, + List rawDataTypes, + TimeRange timeRange, + MetadataFilter metadataFilter, + Integer limit, + String cursor) { + + public static Builder builder() { + return new Builder(); + } + + public QueryMemoryItemsRequest(String userId, String agentId) { + this(userId, agentId, null, null, null, null, null, null, null, null); + } + + public record TimeRange(String field, Instant from, Instant to) {} + + public static final class Builder { + + private String userId; + private String agentId; + private String scope; + private List categories; + private List sourceClients; + private List rawDataTypes; + private TimeRange timeRange; + private MetadataFilter metadataFilter; + private Integer limit; + private String cursor; + + public Builder userId(String userId) { + this.userId = userId; + return this; + } + + public Builder agentId(String agentId) { + this.agentId = agentId; + return this; + } + + public Builder scope(String scope) { + this.scope = scope; + return this; + } + + public Builder categories(List categories) { + this.categories = categories; + return this; + } + + public Builder sourceClients(List sourceClients) { + this.sourceClients = sourceClients; + return this; + } + + public Builder rawDataTypes(List rawDataTypes) { + this.rawDataTypes = rawDataTypes; + return this; + } + + public Builder timeRange(TimeRange timeRange) { + this.timeRange = timeRange; + return this; + } + + public Builder metadataFilter(MetadataFilter metadataFilter) { + this.metadataFilter = metadataFilter; + return this; + } + + public Builder limit(Integer limit) { + this.limit = limit; + return this; + } + + public Builder cursor(String cursor) { + this.cursor = cursor; + return this; + } + + public QueryMemoryItemsRequest build() { + Objects.requireNonNull(userId, "userId"); + Objects.requireNonNull(agentId, "agentId"); + return new QueryMemoryItemsRequest( + userId, + agentId, + scope, + categories, + sourceClients, + rawDataTypes, + timeRange, + metadataFilter, + limit, + cursor); + } + } +} diff --git a/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/request/QueryMemoryRawDataRequest.java b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/request/QueryMemoryRawDataRequest.java new file mode 100644 index 00000000..fe5bb3d9 --- /dev/null +++ b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/request/QueryMemoryRawDataRequest.java @@ -0,0 +1,117 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.client.model.request; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record QueryMemoryRawDataRequest( + String userId, + String agentId, + List types, + List sourceClients, + TimeRange timeRange, + MetadataFilter metadataFilter, + IncludeOptions include, + Integer limit, + String cursor) { + + public static Builder builder() { + return new Builder(); + } + + public QueryMemoryRawDataRequest(String userId, String agentId) { + this(userId, agentId, null, null, null, null, null, null, null); + } + + public record TimeRange(String field, Instant from, Instant to) {} + + public record IncludeOptions(Boolean segment, Boolean metadata) {} + + public static final class Builder { + + private String userId; + private String agentId; + private List types; + private List sourceClients; + private TimeRange timeRange; + private MetadataFilter metadataFilter; + private IncludeOptions include; + private Integer limit; + private String cursor; + + public Builder userId(String userId) { + this.userId = userId; + return this; + } + + public Builder agentId(String agentId) { + this.agentId = agentId; + return this; + } + + public Builder types(List types) { + this.types = types; + return this; + } + + public Builder sourceClients(List sourceClients) { + this.sourceClients = sourceClients; + return this; + } + + public Builder timeRange(TimeRange timeRange) { + this.timeRange = timeRange; + return this; + } + + public Builder metadataFilter(MetadataFilter metadataFilter) { + this.metadataFilter = metadataFilter; + return this; + } + + public Builder include(IncludeOptions include) { + this.include = include; + return this; + } + + public Builder limit(Integer limit) { + this.limit = limit; + return this; + } + + public Builder cursor(String cursor) { + this.cursor = cursor; + return this; + } + + public QueryMemoryRawDataRequest build() { + Objects.requireNonNull(userId, "userId"); + Objects.requireNonNull(agentId, "agentId"); + return new QueryMemoryRawDataRequest( + userId, + agentId, + types, + sourceClients, + timeRange, + metadataFilter, + include, + limit, + cursor); + } + } +} diff --git a/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/request/RetrieveMemoryRequest.java b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/request/RetrieveMemoryRequest.java index 209571fe..eef4e120 100644 --- a/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/request/RetrieveMemoryRequest.java +++ b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/request/RetrieveMemoryRequest.java @@ -15,16 +15,36 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.openmemind.ai.client.model.common.Strategy; +import java.time.Instant; +import java.util.List; import java.util.Objects; @JsonInclude(JsonInclude.Include.NON_NULL) public record RetrieveMemoryRequest( - String userId, String agentId, String query, Strategy strategy, Boolean trace) { + String userId, + String agentId, + String query, + Strategy strategy, + Boolean trace, + String scope, + List categories, + TimeRange timeRange, + MetadataFilter metadataFilter, + IncludeOptions include) { public static Builder builder() { return new Builder(); } + public RetrieveMemoryRequest( + String userId, String agentId, String query, Strategy strategy, Boolean trace) { + this(userId, agentId, query, strategy, trace, null, null, null, null, null); + } + + public record TimeRange(String field, Instant from, Instant to) {} + + public record IncludeOptions(Boolean rawDataMetadata, Boolean rawDataSegment) {} + public static final class Builder { private String userId; @@ -32,6 +52,11 @@ public static final class Builder { private String query; private Strategy strategy; private Boolean trace; + private String scope; + private List categories; + private TimeRange timeRange; + private MetadataFilter metadataFilter; + private IncludeOptions include; public Builder userId(String userId) { this.userId = userId; @@ -58,12 +83,47 @@ public Builder trace(Boolean trace) { return this; } + public Builder scope(String scope) { + this.scope = scope; + return this; + } + + public Builder categories(List categories) { + this.categories = categories; + return this; + } + + public Builder timeRange(TimeRange timeRange) { + this.timeRange = timeRange; + return this; + } + + public Builder metadataFilter(MetadataFilter metadataFilter) { + this.metadataFilter = metadataFilter; + return this; + } + + public Builder include(IncludeOptions include) { + this.include = include; + return this; + } + public RetrieveMemoryRequest build() { Objects.requireNonNull(userId, "userId"); Objects.requireNonNull(agentId, "agentId"); Objects.requireNonNull(query, "query"); Objects.requireNonNull(strategy, "strategy"); - return new RetrieveMemoryRequest(userId, agentId, query, strategy, trace); + return new RetrieveMemoryRequest( + userId, + agentId, + query, + strategy, + trace, + scope, + categories, + timeRange, + metadataFilter, + include); } } } diff --git a/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/response/QueryMemoryItemsResponse.java b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/response/QueryMemoryItemsResponse.java new file mode 100644 index 00000000..0d2719a7 --- /dev/null +++ b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/response/QueryMemoryItemsResponse.java @@ -0,0 +1,38 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.client.model.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record QueryMemoryItemsResponse(List items, String nextCursor) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record MemoryItem( + String id, + String text, + String scope, + String category, + String type, + String rawDataId, + String rawDataType, + String sourceClient, + Instant occurredAt, + Instant observedAt, + Instant createdAt, + Map metadata) {} +} diff --git a/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/response/QueryMemoryRawDataResponse.java b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/response/QueryMemoryRawDataResponse.java new file mode 100644 index 00000000..6e6d5438 --- /dev/null +++ b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/response/QueryMemoryRawDataResponse.java @@ -0,0 +1,35 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.client.model.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record QueryMemoryRawDataResponse(List rawData, String nextCursor) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record MemoryRawData( + String id, + String type, + String sourceClient, + String caption, + Map metadata, + Map segment, + Instant startTime, + Instant endTime, + Instant createdAt) {} +} diff --git a/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/response/RetrieveMemoryResponse.java b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/response/RetrieveMemoryResponse.java index 5dc93602..cf6d44ec 100644 --- a/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/response/RetrieveMemoryResponse.java +++ b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/response/RetrieveMemoryResponse.java @@ -54,5 +54,14 @@ public record RetrievedInsight(String id, String text, String tier) {} @JsonIgnoreProperties(ignoreUnknown = true) public record RetrievedRawData( - String rawDataId, String caption, double maxScore, List itemIds) {} + String rawDataId, + String caption, + double maxScore, + List itemIds, + String type, + String sourceClient, + Map metadata, + Instant startTime, + Instant endTime, + Instant createdAt) {} } diff --git a/memind-clients/java/memind-client/src/test/java/com/openmemind/ai/client/MemindClientTest.java b/memind-clients/java/memind-client/src/test/java/com/openmemind/ai/client/MemindClientTest.java index aaf315f0..a34acd1d 100644 --- a/memind-clients/java/memind-client/src/test/java/com/openmemind/ai/client/MemindClientTest.java +++ b/memind-clients/java/memind-client/src/test/java/com/openmemind/ai/client/MemindClientTest.java @@ -35,10 +35,16 @@ import com.openmemind.ai.client.model.request.AddMessageRequest; import com.openmemind.ai.client.model.request.CommitMemoryRequest; import com.openmemind.ai.client.model.request.ExtractMemoryRequest; +import com.openmemind.ai.client.model.request.MetadataFilter; +import com.openmemind.ai.client.model.request.QueryMemoryItemsRequest; +import com.openmemind.ai.client.model.request.QueryMemoryRawDataRequest; import com.openmemind.ai.client.model.request.RetrieveMemoryRequest; import com.openmemind.ai.client.model.response.ExtractMemoryResponse; import com.openmemind.ai.client.model.response.HealthResponse; +import com.openmemind.ai.client.model.response.QueryMemoryItemsResponse; +import com.openmemind.ai.client.model.response.QueryMemoryRawDataResponse; import com.openmemind.ai.client.model.response.RetrieveMemoryResponse; +import java.time.Instant; import java.util.List; import org.junit.jupiter.api.Test; @@ -252,7 +258,7 @@ void retrieve_returnsMemories(WireMockRuntimeInfo wmInfo) { """ {"data":{ "status":"success","items":[{"id":"1","text":"memory text","vectorScore":0.9,"finalScore":0.85}], - "insights":[],"rawData":[],"evidences":[],"strategy":"SIMPLE","query":"test" + "insights":[],"rawData":[{"rawDataId":"rd-1","type":"agent_timeline","sourceClient":"claude-code","metadata":{"sessionId":"s1"}}],"evidences":[],"strategy":"SIMPLE","query":"test" }} """))); @@ -264,12 +270,96 @@ void retrieve_returnsMemories(WireMockRuntimeInfo wmInfo) { .agentId("agent-1") .query("test") .strategy(Strategy.SIMPLE) + .scope("ALL") + .categories(List.of("playbook")) + .metadataFilter( + new MetadataFilter( + List.of( + new MetadataFilter.Condition( + "project", "eq", "memind")), + List.of(), + List.of())) .build()); assertThat(response.status()).isEqualTo("success"); assertThat(response.items()).hasSize(1); assertThat(response.items().get(0).text()).isEqualTo("memory text"); + assertThat(response.rawData().get(0).type()).isEqualTo("agent_timeline"); + assertThat(response.rawData().get(0).sourceClient()).isEqualTo("claude-code"); } + + verify( + postRequestedFor(urlEqualTo("/open/v1/memory/retrieve")) + .withRequestBody(matchingJsonPath("$.scope", equalTo("ALL"))) + .withRequestBody(matchingJsonPath("$.categories[0]", equalTo("playbook"))) + .withRequestBody( + matchingJsonPath( + "$.metadataFilter.all[0].path", equalTo("project")))); + } + + @Test + void queryItems_usesStructuredEndpoint(WireMockRuntimeInfo wmInfo) { + stubFor( + post("/open/v1/memory/items/query") + .willReturn( + okJson( + """ + {"data":{"items":[{"id":"101","text":"Run targeted tests.","rawDataType":"agent_timeline","sourceClient":"claude-code","metadata":{"project":"memind"}}],"nextCursor":"101"}} + """))); + + try (MemindClient client = MemindClient.builder().baseUrl(wmInfo.getHttpBaseUrl()).build()) { + QueryMemoryItemsResponse response = + client.queryItems( + QueryMemoryItemsRequest.builder() + .userId("user-1") + .agentId("agent-1") + .categories(List.of("playbook")) + .sourceClients(List.of("claude-code")) + .rawDataTypes(List.of("agent_timeline")) + .limit(10) + .build()); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).rawDataType()).isEqualTo("agent_timeline"); + assertThat(response.nextCursor()).isEqualTo("101"); + } + + verify( + postRequestedFor(urlEqualTo("/open/v1/memory/items/query")) + .withRequestBody(matchingJsonPath("$.sourceClients[0]", equalTo("claude-code"))) + .withRequestBody( + matchingJsonPath("$.rawDataTypes[0]", equalTo("agent_timeline")))); + } + + @Test + void queryRawData_usesStructuredEndpoint(WireMockRuntimeInfo wmInfo) { + stubFor( + post("/open/v1/memory/raw-data/query") + .willReturn( + okJson( + """ + {"data":{"rawData":[{"id":"rd-1","type":"agent_timeline","sourceClient":"codex","caption":"Fixed retry test.","metadata":{"sessionId":"s1"},"segment":{"events":[]}}],"nextCursor":null}} + """))); + + try (MemindClient client = MemindClient.builder().baseUrl(wmInfo.getHttpBaseUrl()).build()) { + QueryMemoryRawDataResponse response = + client.queryRawData( + QueryMemoryRawDataRequest.builder() + .userId("user-1") + .agentId("agent-1") + .types(List.of("agent_timeline")) + .sourceClients(List.of("codex")) + .include(new QueryMemoryRawDataRequest.IncludeOptions(true, true)) + .build()); + + assertThat(response.rawData()).hasSize(1); + assertThat(response.rawData().get(0).id()).isEqualTo("rd-1"); + assertThat(response.rawData().get(0).segment()).containsKey("events"); + } + + verify( + postRequestedFor(urlEqualTo("/open/v1/memory/raw-data/query")) + .withRequestBody(matchingJsonPath("$.include.segment", equalTo("true")))); } @Test diff --git a/memind-clients/python/src/memind/__init__.py b/memind-clients/python/src/memind/__init__.py index ca2c0774..94f550a8 100644 --- a/memind-clients/python/src/memind/__init__.py +++ b/memind-clients/python/src/memind/__init__.py @@ -38,14 +38,24 @@ HealthResponse, ImageBlock, MapRawContent, + MemoryItem, + MemoryRawData, MergeView, Message, + MetadataCondition, + MetadataFilter, + QueryMemoryItemsRequest, + QueryMemoryItemsResponse, + QueryMemoryRawDataRequest, + QueryMemoryRawDataResponse, RawContent, RawContentValue, + RawDataQueryIncludeOptions, RetrievalTraceView, RetrievedInsight, RetrievedItem, RetrievedRawData, + RetrieveIncludeOptions, RetrieveMemoryRequest, RetrieveMemoryResponse, Role, @@ -53,6 +63,7 @@ StageView, Strategy, TextBlock, + TimeRange, UrlSource, VideoBlock, ) @@ -73,6 +84,8 @@ "HealthResponse", "ImageBlock", "MapRawContent", + "MemoryItem", + "MemoryRawData", "MemindAPIError", "MemindAuthenticationError", "MemindClient", @@ -82,9 +95,17 @@ "MemindTimeoutError", "MergeView", "Message", + "MetadataCondition", + "MetadataFilter", + "QueryMemoryItemsRequest", + "QueryMemoryItemsResponse", + "QueryMemoryRawDataRequest", + "QueryMemoryRawDataResponse", "RawContent", "RawContentValue", + "RawDataQueryIncludeOptions", "RetrievalTraceView", + "RetrieveIncludeOptions", "RetrieveMemoryRequest", "RetrieveMemoryResponse", "RetrievedInsight", @@ -95,6 +116,7 @@ "StageView", "Strategy", "TextBlock", + "TimeRange", "UrlSource", "VideoBlock", "__version__", diff --git a/memind-clients/python/src/memind/resources/async_memory.py b/memind-clients/python/src/memind/resources/async_memory.py index 7fde59c6..081aa48c 100644 --- a/memind-clients/python/src/memind/resources/async_memory.py +++ b/memind-clients/python/src/memind/resources/async_memory.py @@ -23,8 +23,15 @@ CommitMemoryRequest, ExtractMemoryRequest, ExtractMemoryResponse, + MetadataFilter, + QueryMemoryItemsRequest, + QueryMemoryItemsResponse, + QueryMemoryRawDataRequest, + QueryMemoryRawDataResponse, + RetrieveIncludeOptions, RetrieveMemoryRequest, RetrieveMemoryResponse, + TimeRange, ) from memind.types.message import Message, RawContentValue @@ -112,6 +119,11 @@ async def retrieve( query: str | None = None, strategy: Strategy | None = None, trace: bool | None = None, + scope: str | None = None, + categories: list[str] | None = None, + time_range: TimeRange | None = None, + metadata_filter: MetadataFilter | None = None, + include: RetrieveIncludeOptions | None = None, ) -> RetrieveMemoryResponse: payload = request or RetrieveMemoryRequest( user_id=_required(user_id, "user_id"), @@ -119,6 +131,11 @@ async def retrieve( query=_required(query, "query"), strategy=_required(strategy, "strategy"), trace=trace, + scope=scope, + categories=categories, + time_range=time_range, + metadata_filter=metadata_filter, + include=include, ) result = await self._client._post( "/memory/retrieve", @@ -129,6 +146,26 @@ async def retrieve( assert result is not None return result + async def query_items( + self, + request: QueryMemoryItemsRequest, + ) -> QueryMemoryItemsResponse: + result = await self._client._post( + "/memory/items/query", request, QueryMemoryItemsResponse, retry=True + ) + assert result is not None + return result + + async def query_raw_data( + self, + request: QueryMemoryRawDataRequest, + ) -> QueryMemoryRawDataResponse: + result = await self._client._post( + "/memory/raw-data/query", request, QueryMemoryRawDataResponse, retry=True + ) + assert result is not None + return result + T = TypeVar("T") diff --git a/memind-clients/python/src/memind/resources/memory.py b/memind-clients/python/src/memind/resources/memory.py index 9fb605fa..60aa0546 100644 --- a/memind-clients/python/src/memind/resources/memory.py +++ b/memind-clients/python/src/memind/resources/memory.py @@ -23,8 +23,15 @@ CommitMemoryRequest, ExtractMemoryRequest, ExtractMemoryResponse, + MetadataFilter, + QueryMemoryItemsRequest, + QueryMemoryItemsResponse, + QueryMemoryRawDataRequest, + QueryMemoryRawDataResponse, + RetrieveIncludeOptions, RetrieveMemoryRequest, RetrieveMemoryResponse, + TimeRange, ) from memind.types.message import Message, RawContentValue @@ -112,6 +119,11 @@ def retrieve( query: str | None = None, strategy: Strategy | None = None, trace: bool | None = None, + scope: str | None = None, + categories: list[str] | None = None, + time_range: TimeRange | None = None, + metadata_filter: MetadataFilter | None = None, + include: RetrieveIncludeOptions | None = None, ) -> RetrieveMemoryResponse: payload = request or RetrieveMemoryRequest( user_id=_required(user_id, "user_id"), @@ -119,11 +131,36 @@ def retrieve( query=_required(query, "query"), strategy=_required(strategy, "strategy"), trace=trace, + scope=scope, + categories=categories, + time_range=time_range, + metadata_filter=metadata_filter, + include=include, ) result = self._client._post("/memory/retrieve", payload, RetrieveMemoryResponse, retry=True) assert result is not None return result + def query_items( + self, + request: QueryMemoryItemsRequest, + ) -> QueryMemoryItemsResponse: + result = self._client._post( + "/memory/items/query", request, QueryMemoryItemsResponse, retry=True + ) + assert result is not None + return result + + def query_raw_data( + self, + request: QueryMemoryRawDataRequest, + ) -> QueryMemoryRawDataResponse: + result = self._client._post( + "/memory/raw-data/query", request, QueryMemoryRawDataResponse, retry=True + ) + assert result is not None + return result + T = TypeVar("T") diff --git a/memind-clients/python/src/memind/types/__init__.py b/memind-clients/python/src/memind/types/__init__.py index c1883c8c..00d97f6c 100644 --- a/memind-clients/python/src/memind/types/__init__.py +++ b/memind-clients/python/src/memind/types/__init__.py @@ -21,14 +21,25 @@ ExtractMemoryRequest, ExtractMemoryResponse, FinalView, + MemoryItem, + MemoryRawData, MergeView, + MetadataCondition, + MetadataFilter, + QueryMemoryItemsRequest, + QueryMemoryItemsResponse, + QueryMemoryRawDataRequest, + QueryMemoryRawDataResponse, + RawDataQueryIncludeOptions, RetrievalTraceView, RetrievedInsight, RetrievedItem, RetrievedRawData, + RetrieveIncludeOptions, RetrieveMemoryRequest, RetrieveMemoryResponse, StageView, + TimeRange, ) from memind.types.message import ( AudioBlock, @@ -61,11 +72,21 @@ "HealthResponse", "ImageBlock", "MapRawContent", + "MemoryItem", + "MemoryRawData", "MergeView", "Message", + "MetadataCondition", + "MetadataFilter", + "QueryMemoryItemsRequest", + "QueryMemoryItemsResponse", + "QueryMemoryRawDataRequest", + "QueryMemoryRawDataResponse", "RawContent", "RawContentValue", + "RawDataQueryIncludeOptions", "RetrievalTraceView", + "RetrieveIncludeOptions", "RetrieveMemoryRequest", "RetrieveMemoryResponse", "RetrievedInsight", @@ -76,6 +97,7 @@ "StageView", "Strategy", "TextBlock", + "TimeRange", "UrlSource", "VideoBlock", ] diff --git a/memind-clients/python/src/memind/types/memory.py b/memind-clients/python/src/memind/types/memory.py index 6eb1c1aa..44a45639 100644 --- a/memind-clients/python/src/memind/types/memory.py +++ b/memind-clients/python/src/memind/types/memory.py @@ -64,6 +64,64 @@ class RetrieveMemoryRequest(MemindModel): query: str strategy: Strategy trace: bool | None = None + scope: str | None = None + categories: list[str] | None = None + time_range: TimeRange | None = None + metadata_filter: MetadataFilter | None = None + include: RetrieveIncludeOptions | None = None + + +class TimeRange(MemindModel): + field: str | None = None + from_: str | None = Field(default=None, alias="from") + to: str | None = None + + +class MetadataCondition(MemindModel): + path: str + op: str + value: Any | None = None + + +class MetadataFilter(MemindModel): + all: list[MetadataCondition] | None = None + any: list[MetadataCondition] | None = None + not_: list[MetadataCondition] | None = Field(default=None, alias="not") + + +class RetrieveIncludeOptions(MemindModel): + raw_data_metadata: bool | None = None + raw_data_segment: bool | None = None + + +class RawDataQueryIncludeOptions(MemindModel): + segment: bool | None = None + metadata: bool | None = None + + +class QueryMemoryItemsRequest(MemindModel): + user_id: str + agent_id: str + scope: str | None = None + categories: list[str] | None = None + source_clients: list[str] | None = None + raw_data_types: list[str] | None = None + time_range: TimeRange | None = None + metadata_filter: MetadataFilter | None = None + limit: int | None = None + cursor: str | None = None + + +class QueryMemoryRawDataRequest(MemindModel): + user_id: str + agent_id: str + types: list[str] | None = None + source_clients: list[str] | None = None + time_range: TimeRange | None = None + metadata_filter: MetadataFilter | None = None + include: RawDataQueryIncludeOptions | None = None + limit: int | None = None + cursor: str | None = None class RetrievedItem(MemindModel): @@ -87,6 +145,12 @@ class RetrievedRawData(MemindModel): caption: str | None = None max_score: float = 0.0 item_ids: list[str] | None = None + type: str | None = None + source_client: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + start_time: str | None = None + end_time: str | None = None + created_at: str | None = None class StageView(MemindModel): @@ -141,3 +205,40 @@ class RetrieveMemoryResponse(MemindModel): strategy: str | None = None query: str | None = None trace: RetrievalTraceView | None = None + + +class MemoryItem(MemindModel): + id: str + text: str + scope: str | None = None + category: str | None = None + type: str | None = None + raw_data_id: str | None = None + raw_data_type: str | None = None + source_client: str | None = None + occurred_at: str | None = None + observed_at: str | None = None + created_at: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class QueryMemoryItemsResponse(MemindModel): + items: list[MemoryItem] = Field(default_factory=list) + next_cursor: str | None = None + + +class MemoryRawData(MemindModel): + id: str + type: str | None = None + source_client: str | None = None + caption: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + segment: dict[str, Any] | None = None + start_time: str | None = None + end_time: str | None = None + created_at: str | None = None + + +class QueryMemoryRawDataResponse(MemindModel): + raw_data: list[MemoryRawData] = Field(default_factory=list) + next_cursor: str | None = None diff --git a/memind-clients/python/tests/test_async_client.py b/memind-clients/python/tests/test_async_client.py index cdeb27fb..dcad2a70 100644 --- a/memind-clients/python/tests/test_async_client.py +++ b/memind-clients/python/tests/test_async_client.py @@ -18,7 +18,13 @@ from memind._async_client import AsyncMemindClient from memind._exceptions import MemindAPIError, MemindError -from memind.types import ConversationContent, Message, Strategy +from memind.types import ( + ConversationContent, + Message, + QueryMemoryItemsRequest, + QueryMemoryRawDataRequest, + Strategy, +) @pytest.mark.asyncio @@ -150,6 +156,40 @@ async def test_async_retrieve_returns_response(httpx_mock) -> None: assert result.items[0].text == "likes coffee" +@pytest.mark.asyncio +async def test_async_query_methods_return_structured_results(httpx_mock) -> None: + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/items/query", + json={ + "data": { + "items": [{"id": "101", "text": "Run targeted tests.", "metadata": {}}], + "nextCursor": None, + } + }, + ) + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/raw-data/query", + json={ + "data": { + "rawData": [{"id": "rd-1", "type": "agent_timeline", "metadata": {}}], + "nextCursor": None, + } + }, + ) + + client = AsyncMemindClient(base_url="https://api.example.test") + items = await client.memory.query_items(QueryMemoryItemsRequest(user_id="u1", agent_id="a1")) + raw_data = await client.memory.query_raw_data( + QueryMemoryRawDataRequest(user_id="u1", agent_id="a1") + ) + await client.close() + + assert items.items[0].id == "101" + assert raw_data.raw_data[0].id == "rd-1" + + @pytest.mark.asyncio async def test_async_api_error_is_raised_unwrapped(httpx_mock) -> None: httpx_mock.add_response( diff --git a/memind-clients/python/tests/test_client.py b/memind-clients/python/tests/test_client.py index bf8f16cd..4a68365d 100644 --- a/memind-clients/python/tests/test_client.py +++ b/memind-clients/python/tests/test_client.py @@ -22,8 +22,15 @@ ConversationContent, ExtractMemoryResponse, Message, + MetadataCondition, + MetadataFilter, + QueryMemoryItemsRequest, + QueryMemoryRawDataRequest, + RawDataQueryIncludeOptions, RetrieveMemoryRequest, + RetrieveIncludeOptions, Strategy, + TimeRange, ) @@ -217,11 +224,26 @@ def test_retrieve_accepts_expanded_parameters(httpx_mock) -> None: client = MemindClient(base_url="https://api.example.test") result = client.memory.retrieve( - user_id="u1", agent_id="a1", query="coffee", strategy=Strategy.SIMPLE, trace=True + user_id="u1", + agent_id="a1", + query="coffee", + strategy=Strategy.SIMPLE, + trace=True, + scope="ALL", + categories=["profile"], + time_range=TimeRange(field="occurredAt", from_="2026-01-01T00:00:00Z"), + metadata_filter=MetadataFilter( + all=[MetadataCondition(path="project", op="eq", value="memind")] + ), + include=RetrieveIncludeOptions(raw_data_metadata=True), ) assert result.items[0].text == "likes coffee" - assert b'"trace":true' in httpx_mock.get_request().content + content = httpx_mock.get_request().content + assert b'"trace":true' in content + assert b'"scope":"ALL"' in content + assert b'"categories":["profile"]' in content + assert b'"metadataFilter":{"all":[{"path":"project","op":"eq","value":"memind"}]}' in content client.close() @@ -242,6 +264,87 @@ def test_retrieve_accepts_request_object(httpx_mock) -> None: client.close() +def test_query_items_posts_to_open_query_endpoint(httpx_mock) -> None: + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/items/query", + json={ + "data": { + "items": [ + { + "id": "101", + "text": "Use mvn -pl memind-server test.", + "scope": "AGENT", + "category": "playbook", + "rawDataId": "rd-1", + "rawDataType": "agent_timeline", + "sourceClient": "claude-code", + "metadata": {"project": "memind"}, + } + ], + "nextCursor": "101", + } + }, + ) + + client = MemindClient(base_url="https://api.example.test") + result = client.memory.query_items( + QueryMemoryItemsRequest( + user_id="u1", + agent_id="a1", + categories=["playbook"], + source_clients=["claude-code"], + raw_data_types=["agent_timeline"], + limit=10, + ) + ) + + assert result.items[0].text.startswith("Use mvn") + assert result.next_cursor == "101" + content = httpx_mock.get_request().content + assert b'"sourceClients":["claude-code"]' in content + assert b'"rawDataTypes":["agent_timeline"]' in content + client.close() + + +def test_query_raw_data_posts_to_open_query_endpoint(httpx_mock) -> None: + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/raw-data/query", + json={ + "data": { + "rawData": [ + { + "id": "rd-1", + "type": "agent_timeline", + "sourceClient": "codex", + "caption": "Fixed retry test.", + "metadata": {"sessionId": "s1"}, + "segment": {"events": []}, + } + ], + "nextCursor": None, + } + }, + ) + + client = MemindClient(base_url="https://api.example.test") + result = client.memory.query_raw_data( + QueryMemoryRawDataRequest( + user_id="u1", + agent_id="a1", + types=["agent_timeline"], + source_clients=["codex"], + include=RawDataQueryIncludeOptions(segment=True), + ) + ) + + assert result.raw_data[0].id == "rd-1" + assert result.raw_data[0].segment == {"events": []} + assert b'"include":{"segment":true}' in httpx_mock.get_request().content + client.close() + + def test_api_error_is_raised_unwrapped(httpx_mock) -> None: httpx_mock.add_response( method="POST", diff --git a/memind-clients/python/tests/test_models.py b/memind-clients/python/tests/test_models.py index 509d87f4..d5416429 100644 --- a/memind-clients/python/tests/test_models.py +++ b/memind-clients/python/tests/test_models.py @@ -28,6 +28,15 @@ ExtractMemoryResponse, RetrieveMemoryRequest, RetrieveMemoryResponse, + MetadataCondition, + MetadataFilter, + QueryMemoryItemsRequest, + QueryMemoryItemsResponse, + QueryMemoryRawDataRequest, + QueryMemoryRawDataResponse, + RawDataQueryIncludeOptions, + RetrieveIncludeOptions, + TimeRange, ) from memind.types.message import ( Base64Source, @@ -267,6 +276,29 @@ def test_retrieve_memory_request(self) -> None: assert dumped["strategy"] == "DEEP" assert dumped["trace"] is True + def test_retrieve_memory_request_with_filters(self) -> None: + req = RetrieveMemoryRequest( + user_id="u1", + agent_id="a1", + query="recent decisions", + strategy=Strategy.DEEP, + scope="ALL", + categories=["resolution", "playbook"], + time_range=TimeRange(field="occurredAt", from_="2026-01-01T00:00:00Z"), + metadata_filter=MetadataFilter( + all=[MetadataCondition(path="project", op="eq", value="memind")], + not_=[MetadataCondition(path="archived", op="exists")], + ), + include=RetrieveIncludeOptions(raw_data_metadata=True, raw_data_segment=False), + ) + dumped = req.model_dump(by_alias=True, exclude_none=True) + assert dumped["scope"] == "ALL" + assert dumped["categories"] == ["resolution", "playbook"] + assert dumped["timeRange"]["from"] == "2026-01-01T00:00:00Z" + assert dumped["metadataFilter"]["all"][0]["path"] == "project" + assert dumped["metadataFilter"]["not"][0]["op"] == "exists" + assert dumped["include"] == {"rawDataMetadata": True, "rawDataSegment": False} + def test_retrieve_memory_response(self) -> None: data = { "status": "OK", @@ -281,7 +313,18 @@ def test_retrieve_memory_response(self) -> None: ], "insights": [{"id": "ins-1", "text": "prefers hot drinks", "tier": "CORE"}], "rawData": [ - {"rawDataId": "rd-1", "caption": "chat", "maxScore": 0.9, "itemIds": ["item-1"]} + { + "rawDataId": "rd-1", + "caption": "chat", + "maxScore": 0.9, + "itemIds": ["item-1"], + "type": "agent_timeline", + "sourceClient": "claude-code", + "metadata": {"sessionId": "s1"}, + "startTime": "2026-01-01T00:00:00Z", + "endTime": "2026-01-01T00:01:00Z", + "createdAt": "2026-01-01T00:02:00Z", + } ], "evidences": ["evidence-1"], "strategy": "SIMPLE", @@ -296,8 +339,90 @@ def test_retrieve_memory_response(self) -> None: assert resp.items[0].occurred_at == "2026-01-01T00:00:00Z" assert resp.insights[0].tier == "CORE" assert resp.raw_data[0].raw_data_id == "rd-1" + assert resp.raw_data[0].type == "agent_timeline" + assert resp.raw_data[0].source_client == "claude-code" + assert resp.raw_data[0].metadata == {"sessionId": "s1"} assert resp.evidences == ["evidence-1"] + def test_query_items_models(self) -> None: + req = QueryMemoryItemsRequest( + user_id="u1", + agent_id="a1", + scope="ALL", + categories=["resolution"], + source_clients=["claude-code"], + raw_data_types=["agent_timeline"], + time_range=TimeRange(field="occurredAt", to="2026-05-01T00:00:00Z"), + metadata_filter=MetadataFilter( + any=[MetadataCondition(path="repo", op="contains", value="memind")] + ), + limit=10, + cursor="item-9", + ) + dumped = req.model_dump(by_alias=True, exclude_none=True) + assert dumped["sourceClients"] == ["claude-code"] + assert dumped["rawDataTypes"] == ["agent_timeline"] + assert dumped["timeRange"]["to"] == "2026-05-01T00:00:00Z" + + response = QueryMemoryItemsResponse.model_validate( + { + "items": [ + { + "id": "101", + "text": "Run Java tests before pushing.", + "scope": "AGENT", + "category": "playbook", + "type": "FACT", + "rawDataId": "rd-1", + "rawDataType": "agent_timeline", + "sourceClient": "claude-code", + "occurredAt": "2026-05-01T00:00:00Z", + "observedAt": "2026-05-01T00:01:00Z", + "createdAt": "2026-05-01T00:02:00Z", + "metadata": {"repo": "memind"}, + } + ], + "nextCursor": "101", + } + ) + assert response.items[0].raw_data_type == "agent_timeline" + assert response.items[0].metadata == {"repo": "memind"} + assert response.next_cursor == "101" + + def test_query_raw_data_models(self) -> None: + req = QueryMemoryRawDataRequest( + user_id="u1", + agent_id="a1", + types=["agent_timeline"], + source_clients=["codex"], + include=RawDataQueryIncludeOptions(segment=True, metadata=True), + limit=5, + ) + dumped = req.model_dump(by_alias=True, exclude_none=True) + assert dumped["types"] == ["agent_timeline"] + assert dumped["include"] == {"segment": True, "metadata": True} + + response = QueryMemoryRawDataResponse.model_validate( + { + "rawData": [ + { + "id": "rd-1", + "type": "agent_timeline", + "sourceClient": "codex", + "caption": "Fixed retry test.", + "metadata": {"sessionId": "s1"}, + "segment": {"events": []}, + "startTime": "2026-05-01T00:00:00Z", + "endTime": "2026-05-01T00:01:00Z", + "createdAt": "2026-05-01T00:02:00Z", + } + ], + "nextCursor": None, + } + ) + assert response.raw_data[0].id == "rd-1" + assert response.raw_data[0].segment == {"events": []} + def test_retrieve_memory_response_with_trace(self) -> None: data = { "status": "OK", diff --git a/memind-clients/python/tests/test_public_api.py b/memind-clients/python/tests/test_public_api.py index d7621fd8..4e0ea8d9 100644 --- a/memind-clients/python/tests/test_public_api.py +++ b/memind-clients/python/tests/test_public_api.py @@ -22,8 +22,13 @@ MemindClient, MemindError, Message, + MetadataCondition, + MetadataFilter, + QueryMemoryItemsRequest, + QueryMemoryRawDataRequest, RawContentValue, Strategy, + TimeRange, ) @@ -37,3 +42,7 @@ def test_public_exports() -> None: assert Message.user("hello").role.value == "USER" assert Strategy.SIMPLE.value == "SIMPLE" assert ConversationContent(messages=[Message.user("hi")]).type == "conversation" + assert MetadataFilter(all=[MetadataCondition(path="project", op="eq", value="memind")]) + assert QueryMemoryItemsRequest(user_id="u1", agent_id="a1") + assert QueryMemoryRawDataRequest(user_id="u1", agent_id="a1") + assert TimeRange(field="occurredAt") diff --git a/memind-clients/rust/src/lib.rs b/memind-clients/rust/src/lib.rs index 54e61fd3..72870462 100644 --- a/memind-clients/rust/src/lib.rs +++ b/memind-clients/rust/src/lib.rs @@ -26,8 +26,11 @@ pub use config::RequestOptions; pub use error::{MemindApiError, MemindError, Result}; pub use models::{ AddMessageRequest, AddMessageResponse, CommitMemoryRequest, ContentBlock, ExtractMemoryRequest, - ExtractMemoryResponse, ExtractStatus, FinalView, HealthResponse, MergeView, Message, - RawContent, RetrievalTraceView, RetrieveMemoryRequest, RetrieveMemoryResponse, - RetrievedInsight, RetrievedItem, RetrievedRawData, Role, Source, StageView, Strategy, + ExtractMemoryResponse, ExtractStatus, FinalView, HealthResponse, MemoryItem, MemoryRawData, + MergeView, Message, MetadataCondition, MetadataFilter, QueryMemoryItemsRequest, + QueryMemoryItemsResponse, QueryMemoryRawDataRequest, QueryMemoryRawDataResponse, RawContent, + RawDataQueryIncludeOptions, RetrievalTraceView, RetrieveIncludeOptions, RetrieveMemoryRequest, + RetrieveMemoryResponse, RetrievedInsight, RetrievedItem, RetrievedRawData, Role, Source, + StageView, Strategy, TimeRange, }; pub use resources::MemoryClient; diff --git a/memind-clients/rust/src/models/memory.rs b/memind-clients/rust/src/models/memory.rs index 7045da24..957e6ffe 100644 --- a/memind-clients/rust/src/models/memory.rs +++ b/memind-clients/rust/src/models/memory.rs @@ -119,6 +119,16 @@ pub struct RetrieveMemoryRequest { pub strategy: Strategy, #[serde(default, skip_serializing_if = "Option::is_none")] pub trace: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub categories: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub time_range: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata_filter: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub include: Option, } impl RetrieveMemoryRequest { @@ -134,6 +144,11 @@ impl RetrieveMemoryRequest { query: query.into(), strategy, trace: None, + scope: None, + categories: Vec::new(), + time_range: None, + metadata_filter: None, + include: None, } } @@ -142,6 +157,46 @@ impl RetrieveMemoryRequest { self } + pub fn scope(mut self, scope: impl Into) -> Self { + self.scope = normalize_optional_string(scope.into()); + self + } + + pub fn category(mut self, category: impl Into) -> Self { + let category = category.into(); + if let Some(category) = normalize_optional_string(category) { + self.categories.push(category); + } + self + } + + pub fn categories(mut self, categories: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.categories = categories + .into_iter() + .filter_map(|category| normalize_optional_string(category.into())) + .collect(); + self + } + + pub fn time_range(mut self, time_range: TimeRange) -> Self { + self.time_range = Some(time_range); + self + } + + pub fn metadata_filter(mut self, metadata_filter: MetadataFilter) -> Self { + self.metadata_filter = Some(metadata_filter); + self + } + + pub fn include(mut self, include: RetrieveIncludeOptions) -> Self { + self.include = Some(include); + self + } + pub(crate) fn validate(&self) -> Result<()> { validate_identity(&self.user_id, &self.agent_id)?; if self.query.trim().is_empty() { @@ -151,6 +206,239 @@ impl RetrieveMemoryRequest { } } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TimeRange { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub field: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub from: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub to: Option, +} + +impl TimeRange { + pub fn new(field: impl Into) -> Self { + Self { + field: normalize_optional_string(field.into()), + from: None, + to: None, + } + } + + pub fn from(mut self, from: impl Into) -> Self { + self.from = normalize_optional_string(from.into()); + self + } + + pub fn to(mut self, to: impl Into) -> Self { + self.to = normalize_optional_string(to.into()); + self + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MetadataCondition { + pub path: String, + pub op: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, +} + +impl MetadataCondition { + pub fn new( + path: impl Into, + op: impl Into, + value: impl Into, + ) -> Self { + Self { + path: path.into(), + op: op.into(), + value: Some(value.into()), + } + } + + pub fn flag(path: impl Into, op: impl Into) -> Self { + Self { + path: path.into(), + op: op.into(), + value: None, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MetadataFilter { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub all: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub any: Vec, + #[serde(default, rename = "not", skip_serializing_if = "Vec::is_empty")] + pub not_: Vec, +} + +impl MetadataFilter { + pub fn all(all: Vec) -> Self { + Self { + all, + any: Vec::new(), + not_: Vec::new(), + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RetrieveIncludeOptions { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw_data_metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw_data_segment: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RawDataQueryIncludeOptions { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub segment: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryMemoryItemsRequest { + pub user_id: String, + pub agent_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub categories: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub source_clients: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub raw_data_types: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub time_range: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata_filter: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +impl QueryMemoryItemsRequest { + pub fn new(user_id: impl Into, agent_id: impl Into) -> Self { + Self { + user_id: user_id.into(), + agent_id: agent_id.into(), + scope: None, + categories: Vec::new(), + source_clients: Vec::new(), + raw_data_types: Vec::new(), + time_range: None, + metadata_filter: None, + limit: None, + cursor: None, + } + } + + pub fn category(mut self, category: impl Into) -> Self { + if let Some(category) = normalize_optional_string(category.into()) { + self.categories.push(category); + } + self + } + + pub fn source_client(mut self, source_client: impl Into) -> Self { + if let Some(source_client) = normalize_optional_string(source_client.into()) { + self.source_clients.push(source_client); + } + self + } + + pub fn raw_data_type(mut self, raw_data_type: impl Into) -> Self { + if let Some(raw_data_type) = normalize_optional_string(raw_data_type.into()) { + self.raw_data_types.push(raw_data_type); + } + self + } + + pub fn limit(mut self, limit: u32) -> Self { + self.limit = Some(limit); + self + } + + pub(crate) fn validate(&self) -> Result<()> { + validate_identity(&self.user_id, &self.agent_id)?; + validate_limit(self.limit) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryMemoryRawDataRequest { + pub user_id: String, + pub agent_id: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub types: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub source_clients: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub time_range: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata_filter: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub include: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +impl QueryMemoryRawDataRequest { + pub fn new(user_id: impl Into, agent_id: impl Into) -> Self { + Self { + user_id: user_id.into(), + agent_id: agent_id.into(), + types: Vec::new(), + source_clients: Vec::new(), + time_range: None, + metadata_filter: None, + include: None, + limit: None, + cursor: None, + } + } + + pub fn raw_data_type(mut self, raw_data_type: impl Into) -> Self { + if let Some(raw_data_type) = normalize_optional_string(raw_data_type.into()) { + self.types.push(raw_data_type); + } + self + } + + pub fn source_client(mut self, source_client: impl Into) -> Self { + if let Some(source_client) = normalize_optional_string(source_client.into()) { + self.source_clients.push(source_client); + } + self + } + + pub fn include(mut self, include: RawDataQueryIncludeOptions) -> Self { + self.include = Some(include); + self + } + + pub(crate) fn validate(&self) -> Result<()> { + validate_identity(&self.user_id, &self.agent_id)?; + validate_limit(self.limit) + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExtractMemoryResponse { @@ -213,6 +501,10 @@ pub struct RetrievedItem { skip_serializing_if = "Option::is_none" )] pub occurred_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option>, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -234,6 +526,121 @@ pub struct RetrievedRawData { pub max_score: f64, #[serde(default)] pub item_ids: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub r#type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_client: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option>, + #[serde( + default, + with = "time::serde::rfc3339::option", + skip_serializing_if = "Option::is_none" + )] + pub start_time: Option, + #[serde( + default, + with = "time::serde::rfc3339::option", + skip_serializing_if = "Option::is_none" + )] + pub end_time: Option, + #[serde( + default, + with = "time::serde::rfc3339::option", + skip_serializing_if = "Option::is_none" + )] + pub created_at: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryMemoryItemsResponse { + #[serde(default)] + pub items: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryItem { + pub id: String, + pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub r#type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw_data_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw_data_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_client: Option, + #[serde( + default, + with = "time::serde::rfc3339::option", + skip_serializing_if = "Option::is_none" + )] + pub occurred_at: Option, + #[serde( + default, + with = "time::serde::rfc3339::option", + skip_serializing_if = "Option::is_none" + )] + pub observed_at: Option, + #[serde( + default, + with = "time::serde::rfc3339::option", + skip_serializing_if = "Option::is_none" + )] + pub created_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryMemoryRawDataResponse { + #[serde(default)] + pub raw_data: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MemoryRawData { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub r#type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_client: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub caption: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub segment: Option>, + #[serde( + default, + with = "time::serde::rfc3339::option", + skip_serializing_if = "Option::is_none" + )] + pub start_time: Option, + #[serde( + default, + with = "time::serde::rfc3339::option", + skip_serializing_if = "Option::is_none" + )] + pub end_time: Option, + #[serde( + default, + with = "time::serde::rfc3339::option", + skip_serializing_if = "Option::is_none" + )] + pub created_at: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -344,3 +751,14 @@ fn validate_identity(user_id: &str, agent_id: &str) -> Result<()> { } Ok(()) } + +fn validate_limit(limit: Option) -> Result<()> { + if let Some(limit) = limit { + if !(1..=100).contains(&limit) { + return Err(MemindError::invalid_request( + "limit must be between 1 and 100", + )); + } + } + Ok(()) +} diff --git a/memind-clients/rust/src/models/mod.rs b/memind-clients/rust/src/models/mod.rs index 13792e5c..96bbf2f0 100644 --- a/memind-clients/rust/src/models/mod.rs +++ b/memind-clients/rust/src/models/mod.rs @@ -19,7 +19,10 @@ pub use common::{ExtractStatus, Role, Strategy}; pub use health::HealthResponse; pub use memory::{ AddMessageRequest, AddMessageResponse, CommitMemoryRequest, ExtractMemoryRequest, - ExtractMemoryResponse, FinalView, MergeView, RetrievalTraceView, RetrieveMemoryRequest, - RetrieveMemoryResponse, RetrievedInsight, RetrievedItem, RetrievedRawData, StageView, + ExtractMemoryResponse, FinalView, MemoryItem, MemoryRawData, MergeView, MetadataCondition, + MetadataFilter, QueryMemoryItemsRequest, QueryMemoryItemsResponse, QueryMemoryRawDataRequest, + QueryMemoryRawDataResponse, RawDataQueryIncludeOptions, RetrievalTraceView, + RetrieveIncludeOptions, RetrieveMemoryRequest, RetrieveMemoryResponse, RetrievedInsight, + RetrievedItem, RetrievedRawData, StageView, TimeRange, }; pub use message::{ContentBlock, Message, RawContent, Source}; diff --git a/memind-clients/rust/src/resources/memory.rs b/memind-clients/rust/src/resources/memory.rs index 9a6576f1..81cf0e9b 100644 --- a/memind-clients/rust/src/resources/memory.rs +++ b/memind-clients/rust/src/resources/memory.rs @@ -15,7 +15,9 @@ use std::sync::Arc; use crate::client::ClientInner; use crate::models::{ AddMessageRequest, AddMessageResponse, CommitMemoryRequest, ExtractMemoryRequest, - ExtractMemoryResponse, RetrieveMemoryRequest, RetrieveMemoryResponse, + ExtractMemoryResponse, QueryMemoryItemsRequest, QueryMemoryItemsResponse, + QueryMemoryRawDataRequest, QueryMemoryRawDataResponse, RetrieveMemoryRequest, + RetrieveMemoryResponse, }; use crate::{http, RequestOptions, Result}; @@ -98,4 +100,52 @@ impl MemoryClient { ) .await } + + pub async fn query_items( + &self, + request: QueryMemoryItemsRequest, + ) -> Result { + self.query_items_with_options(request, RequestOptions::new()) + .await + } + + pub async fn query_items_with_options( + &self, + request: QueryMemoryItemsRequest, + options: RequestOptions, + ) -> Result { + request.validate()?; + http::post_json( + &self.inner, + "/memory/items/query", + &request, + options, + self.inner.config.max_retries, + ) + .await + } + + pub async fn query_raw_data( + &self, + request: QueryMemoryRawDataRequest, + ) -> Result { + self.query_raw_data_with_options(request, RequestOptions::new()) + .await + } + + pub async fn query_raw_data_with_options( + &self, + request: QueryMemoryRawDataRequest, + options: RequestOptions, + ) -> Result { + request.validate()?; + http::post_json( + &self.inner, + "/memory/raw-data/query", + &request, + options, + self.inner.config.max_retries, + ) + .await + } } diff --git a/memind-clients/rust/tests/client_test.rs b/memind-clients/rust/tests/client_test.rs index 591ebe8c..c6c30d12 100644 --- a/memind-clients/rust/tests/client_test.rs +++ b/memind-clients/rust/tests/client_test.rs @@ -15,7 +15,8 @@ use std::time::Duration; use memind::{ AddMessageRequest, CommitMemoryRequest, ExtractMemoryRequest, MemindClient, MemindError, - RawContent, RequestOptions, RetrieveMemoryRequest, + QueryMemoryItemsRequest, QueryMemoryRawDataRequest, RawContent, RequestOptions, + RetrieveMemoryRequest, }; use reqwest::header::{HeaderName, HeaderValue, AUTHORIZATION, CONTENT_TYPE, USER_AGENT}; use serde_json::json; @@ -368,6 +369,14 @@ async fn memory_methods_call_expected_endpoints() { "/open/v1/memory/retrieve", json!({"items": [], "insights": [], "rawData": [], "evidences": []}), ), + ( + "/open/v1/memory/items/query", + json!({"items": [{"id": "101", "text": "Run targeted tests.", "rawDataType": "agent_timeline", "sourceClient": "claude-code", "metadata": {"project": "memind"}}], "nextCursor": "101"}), + ), + ( + "/open/v1/memory/raw-data/query", + json!({"rawData": [{"id": "rd-1", "type": "agent_timeline", "sourceClient": "codex", "caption": "Fixed retry test.", "metadata": {"sessionId": "s1"}, "segment": {"events": []}}]}), + ), ] { Mock::given(method("POST")) .and(path(endpoint)) @@ -411,6 +420,34 @@ async fn memory_methods_call_expected_endpoints() { )) .await .unwrap(); + let items = client + .memory() + .query_items( + QueryMemoryItemsRequest::new("u1", "a1") + .category("playbook") + .source_client("claude-code") + .raw_data_type("agent_timeline") + .limit(10), + ) + .await + .unwrap(); + assert_eq!( + items.items[0].raw_data_type.as_deref(), + Some("agent_timeline") + ); + assert_eq!(items.next_cursor.as_deref(), Some("101")); + + let raw_data = client + .memory() + .query_raw_data( + QueryMemoryRawDataRequest::new("u1", "a1") + .raw_data_type("agent_timeline") + .source_client("codex"), + ) + .await + .unwrap(); + assert_eq!(raw_data.raw_data[0].id, "rd-1"); + assert!(raw_data.raw_data[0].segment.is_some()); } #[tokio::test] diff --git a/memind-clients/rust/tests/public_api_test.rs b/memind-clients/rust/tests/public_api_test.rs index f6df986d..b091decd 100644 --- a/memind-clients/rust/tests/public_api_test.rs +++ b/memind-clients/rust/tests/public_api_test.rs @@ -13,8 +13,11 @@ use memind::{ AddMessageRequest, AddMessageResponse, ClientBuilder, CommitMemoryRequest, ContentBlock, ExtractMemoryRequest, ExtractMemoryResponse, ExtractStatus, HealthResponse, MemindApiError, - MemindClient, MemindError, MemoryClient, Message, RawContent, RequestOptions, - RetrieveMemoryRequest, RetrieveMemoryResponse, RetrievedItem, Role, Source, Strategy, + MemindClient, MemindError, MemoryClient, Message, MetadataCondition, MetadataFilter, + QueryMemoryItemsRequest, QueryMemoryItemsResponse, QueryMemoryRawDataRequest, + QueryMemoryRawDataResponse, RawContent, RawDataQueryIncludeOptions, RequestOptions, + RetrieveIncludeOptions, RetrieveMemoryRequest, RetrieveMemoryResponse, RetrievedItem, Role, + Source, Strategy, TimeRange, }; #[test] @@ -43,4 +46,13 @@ fn public_api_exports_expected_names() { assert_type::(); assert_type::(); assert_type::(); + assert_type::(); + assert_type::(); + assert_type::(); + assert_type::(); + assert_type::(); + assert_type::(); + assert_type::(); + assert_type::(); + assert_type::(); } diff --git a/memind-clients/rust/tests/serialization_test.rs b/memind-clients/rust/tests/serialization_test.rs index ab0c7db0..2f6f5450 100644 --- a/memind-clients/rust/tests/serialization_test.rs +++ b/memind-clients/rust/tests/serialization_test.rs @@ -12,8 +12,9 @@ use memind::{ AddMessageRequest, CommitMemoryRequest, ContentBlock, ExtractMemoryRequest, - ExtractMemoryResponse, ExtractStatus, Message, RawContent, RetrieveMemoryRequest, Role, Source, - Strategy, + ExtractMemoryResponse, ExtractStatus, Message, MetadataCondition, MetadataFilter, + QueryMemoryItemsRequest, QueryMemoryRawDataRequest, RawContent, RawDataQueryIncludeOptions, + RetrieveIncludeOptions, RetrieveMemoryRequest, Role, Source, Strategy, TimeRange, }; use serde_json::json; @@ -184,7 +185,18 @@ fn request_models_serialize_camel_case() { #[test] fn retrieve_request_serializes_strategy_and_trace() { - let request = RetrieveMemoryRequest::new("u1", "a1", "what", Strategy::Deep).trace(true); + let request = RetrieveMemoryRequest::new("u1", "a1", "what", Strategy::Deep) + .trace(true) + .scope("ALL") + .category("resolution") + .time_range(TimeRange::new("occurredAt").from("2026-01-01T00:00:00Z")) + .metadata_filter(MetadataFilter::all(vec![MetadataCondition::new( + "project", "eq", "memind", + )])) + .include(RetrieveIncludeOptions { + raw_data_metadata: Some(true), + raw_data_segment: Some(false), + }); let value = serde_json::to_value(request).unwrap(); assert_eq!( value, @@ -193,11 +205,42 @@ fn retrieve_request_serializes_strategy_and_trace() { "agentId": "a1", "query": "what", "strategy": "DEEP", - "trace": true + "trace": true, + "scope": "ALL", + "categories": ["resolution"], + "timeRange": {"field": "occurredAt", "from": "2026-01-01T00:00:00Z"}, + "metadataFilter": {"all": [{"path": "project", "op": "eq", "value": "memind"}]}, + "include": {"rawDataMetadata": true, "rawDataSegment": false} }) ); } +#[test] +fn structured_query_requests_serialize_camel_case() { + let items = QueryMemoryItemsRequest::new("u1", "a1") + .category("playbook") + .source_client("claude-code") + .raw_data_type("agent_timeline") + .limit(10); + let value = serde_json::to_value(items).unwrap(); + assert_eq!(value["sourceClients"], json!(["claude-code"])); + assert_eq!(value["rawDataTypes"], json!(["agent_timeline"])); + + let raw_data = QueryMemoryRawDataRequest::new("u1", "a1") + .raw_data_type("agent_timeline") + .source_client("codex") + .include(RawDataQueryIncludeOptions { + segment: Some(true), + metadata: Some(false), + }); + let value = serde_json::to_value(raw_data).unwrap(); + assert_eq!(value["types"], json!(["agent_timeline"])); + assert_eq!( + value["include"], + json!({"segment": true, "metadata": false}) + ); +} + #[test] fn add_message_request_omits_blank_source_client() { let request = AddMessageRequest::new("u1", "a1", Message::user("hi")).source_client(" "); diff --git a/memind-clients/typescript/src/core/validate.ts b/memind-clients/typescript/src/core/validate.ts index ff238f6a..bf2fb38a 100644 --- a/memind-clients/typescript/src/core/validate.ts +++ b/memind-clients/typescript/src/core/validate.ts @@ -18,7 +18,11 @@ import type { AddMessageResponse, ExtractMemoryResponse, FinalView, + MemoryItem, + MemoryRawData, MergeView, + QueryMemoryItemsResponse, + QueryMemoryRawDataResponse, RetrievalTraceView, RetrieveMemoryResponse, RetrievedInsight, @@ -199,6 +203,84 @@ function assertRetrievedRawData(value: unknown, index: number): RetrievedRawData if (obj.itemIds !== undefined && obj.itemIds !== null) { rawData.itemIds = assertStringArray(obj.itemIds, `rawData[${index}].itemIds`) } + for (const key of ['type', 'sourceClient', 'startTime', 'endTime', 'createdAt'] as const) { + const value = optionalString(obj[key], `rawData[${index}].${key}`) + if (value !== undefined) rawData[key] = value + } + if (obj.metadata !== undefined && obj.metadata !== null) { + rawData.metadata = objectRecord(obj.metadata, `rawData[${index}].metadata`) + } + return rawData +} + +export function assertQueryMemoryItemsResponse(data: unknown): QueryMemoryItemsResponse { + const obj = objectRecord(data, 'queryItems') + const response: QueryMemoryItemsResponse = { + items: assertArray(obj.items, 'items').map(assertMemoryItem), + } + const nextCursor = optionalString(obj.nextCursor, 'nextCursor') + if (nextCursor !== undefined) response.nextCursor = nextCursor + return response +} + +function assertMemoryItem(value: unknown, index: number): MemoryItem { + const obj = objectRecord(value, `items[${index}]`) + const item: MemoryItem = { + id: assertString(obj.id, `items[${index}].id`), + text: assertString(obj.text, `items[${index}].text`), + } + for (const key of [ + 'scope', + 'category', + 'type', + 'rawDataId', + 'rawDataType', + 'sourceClient', + 'occurredAt', + 'observedAt', + 'createdAt', + ] as const) { + const value = optionalString(obj[key], `items[${index}].${key}`) + if (value !== undefined) item[key] = value + } + if (obj.metadata !== undefined && obj.metadata !== null) { + item.metadata = objectRecord(obj.metadata, `items[${index}].metadata`) + } + return item +} + +export function assertQueryMemoryRawDataResponse(data: unknown): QueryMemoryRawDataResponse { + const obj = objectRecord(data, 'queryRawData') + const response: QueryMemoryRawDataResponse = { + rawData: assertArray(obj.rawData, 'rawData').map(assertMemoryRawData), + } + const nextCursor = optionalString(obj.nextCursor, 'nextCursor') + if (nextCursor !== undefined) response.nextCursor = nextCursor + return response +} + +function assertMemoryRawData(value: unknown, index: number): MemoryRawData { + const obj = objectRecord(value, `rawData[${index}]`) + const rawData: MemoryRawData = { + id: assertString(obj.id, `rawData[${index}].id`), + } + for (const key of [ + 'type', + 'sourceClient', + 'caption', + 'startTime', + 'endTime', + 'createdAt', + ] as const) { + const value = optionalString(obj[key], `rawData[${index}].${key}`) + if (value !== undefined) rawData[key] = value + } + if (obj.metadata !== undefined && obj.metadata !== null) { + rawData.metadata = objectRecord(obj.metadata, `rawData[${index}].metadata`) + } + if (obj.segment !== undefined && obj.segment !== null) { + rawData.segment = objectRecord(obj.segment, `rawData[${index}].segment`) + } return rawData } diff --git a/memind-clients/typescript/src/index.ts b/memind-clients/typescript/src/index.ts index 8f038b96..9afebdea 100644 --- a/memind-clients/typescript/src/index.ts +++ b/memind-clients/typescript/src/index.ts @@ -34,12 +34,23 @@ export type { CommitMemoryRequest, ExtractMemoryRequest, ExtractMemoryResponse, + MemoryItem, + MemoryRawData, + MetadataCondition, + MetadataFilter, + QueryMemoryItemsRequest, + QueryMemoryItemsResponse, + QueryMemoryRawDataRequest, + QueryMemoryRawDataResponse, + RawDataQueryIncludeOptions, RetrievalTraceView, + RetrieveIncludeOptions, RetrieveMemoryRequest, RetrieveMemoryResponse, RetrievedInsight, RetrievedItem, RetrievedRawData, + TimeRange, } from './types/memory.js' export { MemindAPIError, diff --git a/memind-clients/typescript/src/resources/memory.ts b/memind-clients/typescript/src/resources/memory.ts index 185f8c0c..06429d05 100644 --- a/memind-clients/typescript/src/resources/memory.ts +++ b/memind-clients/typescript/src/resources/memory.ts @@ -17,6 +17,8 @@ import { httpRequest } from '../core/http.js' import { assertAddMessageResponse, assertExtractMemoryResponse, + assertQueryMemoryItemsResponse, + assertQueryMemoryRawDataResponse, assertRetrieveMemoryResponse, } from '../core/validate.js' import type { RequestOptions } from '../types/common.js' @@ -25,6 +27,10 @@ import type { CommitMemoryRequest, ExtractMemoryRequest, ExtractMemoryResponse, + QueryMemoryItemsRequest, + QueryMemoryItemsResponse, + QueryMemoryRawDataRequest, + QueryMemoryRawDataResponse, RetrieveMemoryRequest, RetrieveMemoryResponse, } from '../types/memory.js' @@ -91,4 +97,34 @@ export class MemoryResource { }) return assertRetrieveMemoryResponse(data) } + + async queryItems( + request: QueryMemoryItemsRequest, + options?: RequestOptions, + ): Promise { + const data = await httpRequest(this.config, { + method: 'POST', + path: '/memory/items/query', + body: request, + signal: options?.signal, + timeoutMs: options?.timeoutMs, + maxRetries: options?.maxRetries, + }) + return assertQueryMemoryItemsResponse(data) + } + + async queryRawData( + request: QueryMemoryRawDataRequest, + options?: RequestOptions, + ): Promise { + const data = await httpRequest(this.config, { + method: 'POST', + path: '/memory/raw-data/query', + body: request, + signal: options?.signal, + timeoutMs: options?.timeoutMs, + maxRetries: options?.maxRetries, + }) + return assertQueryMemoryRawDataResponse(data) + } } diff --git a/memind-clients/typescript/src/types/memory.ts b/memind-clients/typescript/src/types/memory.ts index e14f1d61..94a280d3 100644 --- a/memind-clients/typescript/src/types/memory.ts +++ b/memind-clients/typescript/src/types/memory.ts @@ -15,6 +15,34 @@ import type { Strategy } from './common.js' import type { MessageValue, RawContentValue } from './message.js' +export type MetadataCondition = { + path: string + op: 'eq' | 'in' | 'exists' | 'missing' | 'contains' | string + value?: unknown +} + +export type MetadataFilter = { + all?: MetadataCondition[] + any?: MetadataCondition[] + not?: MetadataCondition[] +} + +export type TimeRange = { + field?: string + from?: string + to?: string +} + +export type RetrieveIncludeOptions = { + rawDataMetadata?: boolean + rawDataSegment?: boolean +} + +export type RawDataQueryIncludeOptions = { + segment?: boolean + metadata?: boolean +} + export type ExtractMemoryRequest = { userId: string agentId: string @@ -41,6 +69,36 @@ export type RetrieveMemoryRequest = { query: string strategy: Strategy trace?: boolean + scope?: string + categories?: string[] + timeRange?: TimeRange + metadataFilter?: MetadataFilter + include?: RetrieveIncludeOptions +} + +export type QueryMemoryItemsRequest = { + userId: string + agentId: string + scope?: string + categories?: string[] + sourceClients?: string[] + rawDataTypes?: string[] + timeRange?: TimeRange + metadataFilter?: MetadataFilter + limit?: number + cursor?: string +} + +export type QueryMemoryRawDataRequest = { + userId: string + agentId: string + types?: string[] + sourceClients?: string[] + timeRange?: TimeRange + metadataFilter?: MetadataFilter + include?: RawDataQueryIncludeOptions + limit?: number + cursor?: string } export type ExtractMemoryResponse = { @@ -79,6 +137,12 @@ export type RetrievedRawData = { caption?: string maxScore: number itemIds?: string[] + type?: string + sourceClient?: string + metadata?: Record + startTime?: string + endTime?: string + createdAt?: string } export type StageView = { @@ -134,3 +198,40 @@ export type RetrieveMemoryResponse = { query?: string trace?: RetrievalTraceView } + +export type MemoryItem = { + id: string + text: string + scope?: string + category?: string + type?: string + rawDataId?: string + rawDataType?: string + sourceClient?: string + occurredAt?: string + observedAt?: string + createdAt?: string + metadata?: Record +} + +export type QueryMemoryItemsResponse = { + items: MemoryItem[] + nextCursor?: string +} + +export type MemoryRawData = { + id: string + type?: string + sourceClient?: string + caption?: string + metadata?: Record + segment?: Record + startTime?: string + endTime?: string + createdAt?: string +} + +export type QueryMemoryRawDataResponse = { + rawData: MemoryRawData[] + nextCursor?: string +} diff --git a/memind-clients/typescript/tests/client.test.ts b/memind-clients/typescript/tests/client.test.ts index 5c6af964..797aac2b 100644 --- a/memind-clients/typescript/tests/client.test.ts +++ b/memind-clients/typescript/tests/client.test.ts @@ -229,5 +229,122 @@ describe('MemindClient', () => { events: [], }) }) + + it('sends retrieve filters to POST /memory/retrieve', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ + data: { items: [], insights: [], rawData: [], evidences: [] }, + }), + }) + const client = new MemindClient({ + baseUrl: 'http://localhost:8366', + fetch: mockFetch, + }) + + await client.memory.retrieve({ + userId: 'u1', + agentId: 'a1', + query: 'recent project decisions', + strategy: 'DEEP', + scope: 'ALL', + categories: ['resolution'], + timeRange: { field: 'occurredAt', from: '2026-01-01T00:00:00Z' }, + metadataFilter: { all: [{ path: 'project', op: 'eq', value: 'memind' }] }, + include: { rawDataMetadata: true, rawDataSegment: false }, + }) + + const body = JSON.parse(String(mockFetch.mock.calls[0]?.[1]?.body)) + expect(body).toMatchObject({ + scope: 'ALL', + categories: ['resolution'], + timeRange: { field: 'occurredAt', from: '2026-01-01T00:00:00Z' }, + metadataFilter: { all: [{ path: 'project', op: 'eq', value: 'memind' }] }, + include: { rawDataMetadata: true, rawDataSegment: false }, + }) + }) + + it('queries memory items through the structured query endpoint', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ + data: { + items: [ + { + id: '101', + text: 'Run targeted tests.', + rawDataType: 'agent_timeline', + sourceClient: 'claude-code', + metadata: { project: 'memind' }, + }, + ], + nextCursor: '101', + }, + }), + }) + const client = new MemindClient({ + baseUrl: 'http://localhost:8366', + fetch: mockFetch, + }) + + const result = await client.memory.queryItems({ + userId: 'u1', + agentId: 'a1', + categories: ['playbook'], + sourceClients: ['claude-code'], + rawDataTypes: ['agent_timeline'], + limit: 10, + }) + + expect(result.items[0]?.rawDataType).toBe('agent_timeline') + expect(result.nextCursor).toBe('101') + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:8366/open/v1/memory/items/query', + expect.objectContaining({ method: 'POST' }), + ) + }) + + it('queries memory raw data through the structured query endpoint', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ + data: { + rawData: [ + { + id: 'rd-1', + type: 'agent_timeline', + sourceClient: 'codex', + caption: 'Fixed retry test.', + metadata: { sessionId: 's1' }, + segment: { events: [] }, + }, + ], + nextCursor: undefined, + }, + }), + }) + const client = new MemindClient({ + baseUrl: 'http://localhost:8366', + fetch: mockFetch, + }) + + const result = await client.memory.queryRawData({ + userId: 'u1', + agentId: 'a1', + types: ['agent_timeline'], + sourceClients: ['codex'], + include: { segment: true }, + }) + + expect(result.rawData[0]?.id).toBe('rd-1') + expect(result.rawData[0]?.segment).toEqual({ events: [] }) + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:8366/open/v1/memory/raw-data/query', + expect.objectContaining({ method: 'POST' }), + ) + }) }) }) diff --git a/memind-clients/typescript/tests/public-api.test.ts b/memind-clients/typescript/tests/public-api.test.ts index 5666b336..e361339b 100644 --- a/memind-clients/typescript/tests/public-api.test.ts +++ b/memind-clients/typescript/tests/public-api.test.ts @@ -14,7 +14,15 @@ import { describe, expect, it } from 'vitest' import * as api from '../src/index.js' -import type { ApiError, ApiResult, RequestOptions } from '../src/index.js' +import type { + ApiError, + ApiResult, + MetadataFilter, + QueryMemoryItemsRequest, + QueryMemoryRawDataRequest, + RequestOptions, + TimeRange, +} from '../src/index.js' function identityResult(result: ApiResult): ApiResult { return result @@ -55,10 +63,20 @@ describe('public API exports', () => { const success: ApiResult = identityResult({ data: 'ok' }) const failure: ApiResult = identityResult({ error }) const requestOptions: RequestOptions = { timeoutMs: 1000, maxRetries: 1 } + const metadataFilter: MetadataFilter = { + all: [{ path: 'project', op: 'eq', value: 'memind' }], + } + const timeRange: TimeRange = { field: 'occurredAt' } + const itemsRequest: QueryMemoryItemsRequest = { userId: 'u1', agentId: 'a1' } + const rawDataRequest: QueryMemoryRawDataRequest = { userId: 'u1', agentId: 'a1' } expect(success).toEqual({ data: 'ok' }) expect(failure).toEqual({ error }) expect(requestOptions).toEqual({ timeoutMs: 1000, maxRetries: 1 }) + expect(metadataFilter.all?.[0]?.path).toBe('project') + expect(timeRange.field).toBe('occurredAt') + expect(itemsRequest.userId).toBe('u1') + expect(rawDataRequest.agentId).toBe('a1') }) it('exports error classes', () => { diff --git a/memind-clients/typescript/tests/validation.test.ts b/memind-clients/typescript/tests/validation.test.ts index 7b717785..910bc1e2 100644 --- a/memind-clients/typescript/tests/validation.test.ts +++ b/memind-clients/typescript/tests/validation.test.ts @@ -18,6 +18,8 @@ import { assertAddMessageResponse, assertExtractMemoryResponse, assertHealthResponse, + assertQueryMemoryItemsResponse, + assertQueryMemoryRawDataResponse, assertRetrieveMemoryResponse, } from '../src/core/validate.js' @@ -80,7 +82,17 @@ describe('response validators', () => { const response = assertRetrieveMemoryResponse({ items: [{ id: 'item-1', text: 'likes coffee', vectorScore: 0.9, finalScore: 0.8 }], insights: [{ id: 'ins-1', text: 'prefers concise answers' }], - rawData: [{ rawDataId: 'rd-1', maxScore: 0.7, itemIds: ['item-1'] }], + rawData: [ + { + rawDataId: 'rd-1', + maxScore: 0.7, + itemIds: ['item-1'], + type: 'agent_timeline', + sourceClient: 'claude-code', + metadata: { sessionId: 's1' }, + startTime: '2026-01-01T00:00:00Z', + }, + ], evidences: ['evidence-1'], trace: { stages: [{ degraded: false, skipped: false, inputCount: 1 }], @@ -91,6 +103,13 @@ describe('response validators', () => { }) expect(response.items).toEqual([expect.objectContaining({ id: 'item-1' })]) + expect(response.rawData[0]).toEqual( + expect.objectContaining({ + type: 'agent_timeline', + sourceClient: 'claude-code', + metadata: { sessionId: 's1' }, + }), + ) expect(response.trace?.stages).toHaveLength(1) expectParseError(() => assertRetrieveMemoryResponse({ items: null })) expectParseError(() => assertRetrieveMemoryResponse({ items: [{ id: 1, text: 'bad' }] })) @@ -99,4 +118,51 @@ describe('response validators', () => { assertRetrieveMemoryResponse({ items: [], trace: { stages: [{ degraded: 'no' }] } }), ) }) + + it('validates structured item query responses', () => { + const response = assertQueryMemoryItemsResponse({ + items: [ + { + id: '101', + text: 'Run targeted tests.', + scope: 'AGENT', + category: 'playbook', + type: 'FACT', + rawDataId: 'rd-1', + rawDataType: 'agent_timeline', + sourceClient: 'claude-code', + occurredAt: '2026-05-01T00:00:00Z', + metadata: { project: 'memind' }, + }, + ], + nextCursor: '101', + }) + + expect(response.items[0]?.rawDataType).toBe('agent_timeline') + expect(response.items[0]?.metadata).toEqual({ project: 'memind' }) + expect(response.nextCursor).toBe('101') + expectParseError(() => assertQueryMemoryItemsResponse({ items: null })) + expectParseError(() => assertQueryMemoryItemsResponse({ items: [{ id: 101, text: 'bad' }] })) + }) + + it('validates structured raw-data query responses', () => { + const response = assertQueryMemoryRawDataResponse({ + rawData: [ + { + id: 'rd-1', + type: 'agent_timeline', + sourceClient: 'codex', + caption: 'Fixed retry test.', + metadata: { sessionId: 's1' }, + segment: { events: [] }, + }, + ], + nextCursor: null, + }) + + expect(response.rawData[0]?.segment).toEqual({ events: [] }) + expect(response.nextCursor).toBeUndefined() + expectParseError(() => assertQueryMemoryRawDataResponse({ rawData: null })) + expectParseError(() => assertQueryMemoryRawDataResponse({ rawData: [{ id: 1 }] })) + }) }) diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/ItemRetrievalGuard.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/ItemRetrievalGuard.java index 1b767b24..c060a804 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/ItemRetrievalGuard.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/ItemRetrievalGuard.java @@ -14,6 +14,7 @@ package com.openmemind.ai.memory.core.retrieval; import com.openmemind.ai.memory.core.data.MemoryItem; +import com.openmemind.ai.memory.core.retrieval.filter.MetadataFilterMatcher; import com.openmemind.ai.memory.core.retrieval.query.QueryContext; /** @@ -35,6 +36,9 @@ public static boolean allows(MemoryItem item, QueryContext context) { && !context.categories().contains(item.category())) { return false; } + if (!MetadataFilterMatcher.matches(item.metadata(), context.metadataFilter())) { + return false; + } return ForesightFilter.isNotExpired(item); } } diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/RetrievalResult.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/RetrievalResult.java index b065e6a8..194f1be7 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/RetrievalResult.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/RetrievalResult.java @@ -16,9 +16,11 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.openmemind.ai.memory.core.data.enums.InsightTier; import com.openmemind.ai.memory.core.retrieval.scoring.ScoredResult; +import java.time.Instant; import java.time.LocalDate; import java.time.ZoneOffset; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; /** @@ -43,7 +45,27 @@ public record RetrievalResult( /** RawData aggregation result */ public record RawDataResult( - String rawDataId, String caption, double maxScore, List itemIds) {} + String rawDataId, + String caption, + double maxScore, + List itemIds, + String type, + String sourceClient, + Map metadata, + Instant startTime, + Instant endTime, + Instant createdAt) { + + public RawDataResult( + String rawDataId, String caption, double maxScore, List itemIds) { + this(rawDataId, caption, maxScore, itemIds, null, null, Map.of(), null, null, null); + } + + public RawDataResult { + itemIds = itemIds == null ? List.of() : List.copyOf(itemIds); + metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + } + } /** Insight result (no scores, only ID, text, and tier) */ public record InsightResult(String id, String text, InsightTier tier) { diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/filter/MetadataFilter.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/filter/MetadataFilter.java new file mode 100644 index 00000000..fea3549c --- /dev/null +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/filter/MetadataFilter.java @@ -0,0 +1,38 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.core.retrieval.filter; + +import java.util.List; + +/** + * Small, portable metadata filter used by retrieval and public query APIs. + * + *

Version 1 intentionally supports top-level metadata keys only. This keeps the filter portable + * across in-memory stores, SQL-backed stores, and every public client without exposing + * store-specific JSON path semantics. + */ +public record MetadataFilter(List all, List any, List not) { + + public MetadataFilter { + all = all == null ? List.of() : List.copyOf(all); + any = any == null ? List.of() : List.copyOf(any); + not = not == null ? List.of() : List.copyOf(not); + } + + public boolean isEmpty() { + return all.isEmpty() && any.isEmpty() && not.isEmpty(); + } + + public record Condition(String path, String op, Object value) {} +} diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/filter/MetadataFilterMatcher.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/filter/MetadataFilterMatcher.java new file mode 100644 index 00000000..41b2852e --- /dev/null +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/filter/MetadataFilterMatcher.java @@ -0,0 +1,87 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.core.retrieval.filter; + +import java.util.Collection; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +public final class MetadataFilterMatcher { + + private MetadataFilterMatcher() {} + + public static boolean matches(Map metadata, MetadataFilter filter) { + if (filter == null || filter.isEmpty()) { + return true; + } + Map safeMetadata = metadata == null ? Map.of() : metadata; + boolean allMatch = + filter.all().stream().allMatch(condition -> matches(safeMetadata, condition)); + boolean anyMatch = + filter.any().isEmpty() + || filter.any().stream() + .anyMatch(condition -> matches(safeMetadata, condition)); + boolean noneExcluded = + filter.not().stream().noneMatch(condition -> matches(safeMetadata, condition)); + return allMatch && anyMatch && noneExcluded; + } + + private static boolean matches( + Map metadata, MetadataFilter.Condition condition) { + if (condition == null || condition.path() == null || condition.path().isBlank()) { + return true; + } + String op = + condition.op() == null || condition.op().isBlank() + ? "eq" + : condition.op().trim().toLowerCase(Locale.ROOT); + boolean present = metadata.containsKey(condition.path()); + Object actual = metadata.get(condition.path()); + return switch (op) { + case "eq" -> present && valuesEqual(actual, condition.value()); + case "in" -> present && valueIn(actual, condition.value()); + case "exists" -> present && actual != null; + case "missing" -> !present || actual == null; + case "contains" -> present && contains(actual, condition.value()); + default -> false; + }; + } + + private static boolean valueIn(Object actual, Object expected) { + if (expected instanceof Collection collection) { + return collection.stream().anyMatch(value -> valuesEqual(actual, value)); + } + return valuesEqual(actual, expected); + } + + private static boolean contains(Object actual, Object expected) { + if (actual instanceof Collection collection) { + return collection.stream().anyMatch(value -> valuesEqual(value, expected)); + } + if (actual instanceof Map map && expected != null) { + return map.containsKey(String.valueOf(expected)); + } + return actual != null + && expected != null + && String.valueOf(actual).contains(String.valueOf(expected)); + } + + private static boolean valuesEqual(Object actual, Object expected) { + return Objects.equals(actual, expected) + || (actual != null + && expected != null + && String.valueOf(actual).equals(String.valueOf(expected))); + } +} diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/query/QueryContext.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/query/QueryContext.java index 39cf660a..514d70ab 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/query/QueryContext.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/query/QueryContext.java @@ -16,6 +16,7 @@ import com.openmemind.ai.memory.core.data.MemoryId; import com.openmemind.ai.memory.core.data.enums.MemoryCategory; import com.openmemind.ai.memory.core.data.enums.MemoryScope; +import com.openmemind.ai.memory.core.retrieval.filter.MetadataFilter; import java.time.Instant; import java.util.List; import java.util.Map; @@ -50,6 +51,9 @@ public record QueryContext( /** metadata key: Time range end (Instant) */ public static final String META_TIME_RANGE_END = "timeRangeEnd"; + /** metadata key: Structured top-level metadata filter ({@link MetadataFilter}). */ + public static final String META_METADATA_FILTER = "metadataFilter"; + /** Get the query text for vector search (prefer using the rewritten one) */ public String searchQuery() { return rewrittenQuery != null && !rewrittenQuery.isBlank() ? rewrittenQuery : originalQuery; @@ -70,6 +74,13 @@ public boolean hasTimeRange() { return timeRangeStart() != null || timeRangeEnd() != null; } + public MetadataFilter metadataFilter() { + return metadata != null + && metadata.get(META_METADATA_FILTER) instanceof MetadataFilter filter + ? filter + : null; + } + private static Instant castInstant(Object value) { return value instanceof Instant instant ? instant : null; } diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/RawDataAggregator.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/RawDataAggregator.java index e01115c6..e616a9a6 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/RawDataAggregator.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/RawDataAggregator.java @@ -16,7 +16,9 @@ import com.openmemind.ai.memory.core.data.MemoryId; import com.openmemind.ai.memory.core.data.MemoryItem; import com.openmemind.ai.memory.core.data.MemoryRawData; +import com.openmemind.ai.memory.core.retrieval.ItemRetrievalGuard; import com.openmemind.ai.memory.core.retrieval.RetrievalResult; +import com.openmemind.ai.memory.core.retrieval.query.QueryContext; import com.openmemind.ai.memory.core.store.MemoryStore; import java.time.Instant; import java.util.ArrayList; @@ -137,7 +139,16 @@ record Parsed(ScoredResult result, Long itemId) {} if (caption != null && !caption.isBlank()) { rawDataResults.add( new RetrievalResult.RawDataResult( - groupKey, caption, maxScore, itemIds)); + groupKey, + caption, + maxScore, + itemIds, + rawData.map(MemoryRawData::contentType).orElse(null), + rawData.map(MemoryRawData::sourceClient).orElse(null), + rawData.map(MemoryRawData::metadata).orElse(Map.of()), + rawData.map(MemoryRawData::startTime).orElse(null), + rawData.map(MemoryRawData::endTime).orElse(null), + rawData.map(MemoryRawData::createdAt).orElse(null))); } } } @@ -323,4 +334,42 @@ public static List backfillOccurredAt( }) .toList(); } + + public static List filterItems( + List results, QueryContext context, MemoryStore store) { + if (store == null || results.isEmpty()) { + return results; + } + List itemIds = + results.stream() + .filter(result -> result.sourceType() == ScoredResult.SourceType.ITEM) + .map(RawDataAggregator::parseLong) + .filter(Objects::nonNull) + .toList(); + if (itemIds.isEmpty()) { + return results; + } + Map itemsById = + store.itemOperations().getItemsByIds(context.memoryId(), itemIds).stream() + .collect(Collectors.toMap(MemoryItem::id, item -> item, (a, b) -> a)); + return results.stream() + .filter( + result -> { + if (result.sourceType() != ScoredResult.SourceType.ITEM) { + return true; + } + Long itemId = parseLong(result); + MemoryItem item = itemId == null ? null : itemsById.get(itemId); + return item != null && ItemRetrievalGuard.allows(item, context); + }) + .toList(); + } + + private static Long parseLong(ScoredResult result) { + try { + return Long.parseLong(result.sourceId()); + } catch (NumberFormatException e) { + return null; + } + } } diff --git a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/tier/ItemTierRetriever.java b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/tier/ItemTierRetriever.java index cb75bb79..3914913f 100644 --- a/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/tier/ItemTierRetriever.java +++ b/memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/tier/ItemTierRetriever.java @@ -413,8 +413,11 @@ public Mono> searchByKeyword( List decayed = TimeDecay.applyToBm25Only(withTime, context, scoring); - log.debug("searchByKeyword completed: {} results", decayed.size()); - return decayed; + List filtered = + RawDataAggregator.filterItems(decayed, context, memoryStore); + + log.debug("searchByKeyword completed: {} results", filtered.size()); + return filtered; }) .onErrorResume( e -> { diff --git a/memind-core/src/test/java/com/openmemind/ai/memory/core/retrieval/ItemRetrievalGuardTest.java b/memind-core/src/test/java/com/openmemind/ai/memory/core/retrieval/ItemRetrievalGuardTest.java index ffe6ad4c..87e84fa1 100644 --- a/memind-core/src/test/java/com/openmemind/ai/memory/core/retrieval/ItemRetrievalGuardTest.java +++ b/memind-core/src/test/java/com/openmemind/ai/memory/core/retrieval/ItemRetrievalGuardTest.java @@ -21,6 +21,7 @@ import com.openmemind.ai.memory.core.data.enums.MemoryCategory; import com.openmemind.ai.memory.core.data.enums.MemoryItemType; import com.openmemind.ai.memory.core.data.enums.MemoryScope; +import com.openmemind.ai.memory.core.retrieval.filter.MetadataFilter; import com.openmemind.ai.memory.core.retrieval.query.QueryContext; import java.time.Instant; import java.util.List; @@ -50,6 +51,47 @@ void itemRetrievalGuardAppliesScopeCategoryAndForesightFiltersTogether() { assertThat(ItemRetrievalGuard.allows(expiredAgentToolForesight(), context)).isFalse(); } + @Test + void itemRetrievalGuardAppliesStructuredMetadataFilter() { + var context = + new QueryContext( + MEMORY_ID, + "q", + null, + List.of(), + Map.of( + QueryContext.META_METADATA_FILTER, + new MetadataFilter( + List.of( + new MetadataFilter.Condition( + "projectSlug", "eq", "memind")), + List.of(), + List.of())), + null, + null); + + assertThat( + ItemRetrievalGuard.allows( + item( + 201L, + MemoryScope.AGENT, + MemoryCategory.TOOL, + MemoryItemType.FACT, + Map.of("projectSlug", "memind")), + context)) + .isTrue(); + assertThat( + ItemRetrievalGuard.allows( + item( + 202L, + MemoryScope.AGENT, + MemoryCategory.TOOL, + MemoryItemType.FACT, + Map.of("projectSlug", "other")), + context)) + .isFalse(); + } + private static MemoryItem agentToolFact() { return item(101L, MemoryScope.AGENT, MemoryCategory.TOOL, MemoryItemType.FACT, Map.of()); } diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGenerator.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGenerator.java index 0db9f6cf..8c779419 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGenerator.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGenerator.java @@ -14,26 +14,76 @@ package com.openmemind.ai.memory.plugin.rawdata.agent.caption; import com.openmemind.ai.memory.core.extraction.rawdata.caption.CaptionGenerator; +import com.openmemind.ai.memory.core.llm.ChatMessages; +import com.openmemind.ai.memory.core.llm.StructuredChatClient; +import java.util.ArrayList; import java.util.List; import java.util.Map; import reactor.core.publisher.Mono; /** - * Deterministic caption generator for agent episode segments. + * LLM-backed turn summary generator for agent episode segments, with deterministic fallback. */ public final class AgentCaptionGenerator implements CaptionGenerator { + private static final int MAX_CAPTION_CHARS = 1200; + + private static final String SYSTEM_PROMPT = + """ + You summarize one completed coding-agent turn for a memory system. + + The summary will be embedded and shown in retrieval results. It must be factual, \ + concise, and useful for continuing the project later. + + Rules: + - Use only the provided episode text and metadata. + - Do not invent files, tests, commands, decisions, outcomes, or next steps. + - Preserve failed, partial, unknown, and unvalidated outcomes honestly. + - Mention explicit validation only when command/test evidence is present. + - Do not reveal or reconstruct redacted secrets. + - Return a structured response with these fields: + task: the user's concrete request or goal. + outcome: success, failed, partial, cancelled, or unknown. + summary: 1-2 factual sentences describing the result. + keyActions: up to 3 important actions or decisions. + evidence: up to 3 compact evidence lines for files, commands, validations, or \ + failures. + next: explicit follow-up only; otherwise empty. + """; + + private final StructuredChatClient chatClient; + + public AgentCaptionGenerator() { + this(null); + } + + public AgentCaptionGenerator(StructuredChatClient chatClient) { + this.chatClient = chatClient; + } + @Override public Mono generate(String content, Map metadata) { - return Mono.just(caption(content, metadata)); + return generate(content, metadata, null); } @Override public Mono generate(String content, Map metadata, String language) { - return generate(content, metadata); + String fallback = deterministicCaption(content, metadata); + if (chatClient == null || content == null || content.isBlank()) { + return Mono.just(fallback); + } + + var messages = + ChatMessages.systemUser(SYSTEM_PROMPT, userPrompt(content, metadata, language)); + return chatClient + .call(messages, AgentCaptionResponse.class) + .map(AgentCaptionGenerator::toCaption) + .map(caption -> caption.isBlank() ? fallback : caption) + .switchIfEmpty(Mono.just(fallback)) + .onErrorResume(ignored -> Mono.just(fallback)); } - private String caption(String content, Map metadata) { + private String deterministicCaption(String content, Map metadata) { if (metadata == null || metadata.isEmpty()) { return truncate(content, 160); } @@ -51,6 +101,138 @@ private String caption(String content, Map metadata) { return base + " (" + summary + ")"; } + private static String userPrompt( + String content, Map metadata, String language) { + Map safeMetadata = metadata == null ? Map.of() : metadata; + return """ + # Episode Metadata + + targetLanguage: %s + goal: %s + outcome: %s + sourceClient: %s + sessionId: %s + timelineId: %s + project: %s + files: %s + commands: %s + toolNames: %s + failureSignals: %s + eventIds: %s + + # Episode Text + + %s + """ + .formatted( + string(language), + string(safeMetadata.get("goal")), + string(safeMetadata.get("outcome")), + string(safeMetadata.get("sourceClient")), + string(safeMetadata.get("sessionId")), + string(safeMetadata.get("timelineId")), + string(firstPresent(safeMetadata, "projectName", "projectSlug")), + list(safeMetadata.get("files")), + list(safeMetadata.get("commands")), + list(safeMetadata.get("toolNames")), + list(safeMetadata.get("failureSignals")), + list(safeMetadata.get("eventIds")), + content == null ? "" : content); + } + + private static String toCaption(AgentCaptionResponse response) { + if (response == null) { + return ""; + } + var lines = new ArrayList(); + appendLine(lines, "Task", response.task()); + String outcome = titleCase(response.outcome()); + String summary = clean(response.summary()); + if (!outcome.isBlank() || !summary.isBlank()) { + lines.add("Outcome: " + joinSentence(outcome.isBlank() ? "Unknown" : outcome, summary)); + } + appendList(lines, "Key actions", response.keyActions()); + appendList(lines, "Evidence", response.evidence()); + appendLine(lines, "Next", response.next()); + return truncate(String.join("\n\n", lines), MAX_CAPTION_CHARS); + } + + private static void appendLine(List lines, String label, String value) { + String cleaned = clean(value); + if (!cleaned.isBlank()) { + lines.add(label + ": " + ensureSentence(cleaned)); + } + } + + private static void appendList(List lines, String label, List values) { + List cleaned = + values == null + ? List.of() + : values.stream() + .map(AgentCaptionGenerator::clean) + .filter(value -> !value.isBlank()) + .limit(3) + .toList(); + if (cleaned.isEmpty()) { + return; + } + lines.add(label + ":\n" + String.join("\n", cleaned.stream().map("- "::concat).toList())); + } + + private static String joinSentence(String prefix, String suffix) { + String first = ensureSentence(clean(prefix)); + String second = ensureSentence(clean(suffix)); + if (second.isBlank()) { + return first; + } + return first + " " + second; + } + + private static String ensureSentence(String value) { + String cleaned = clean(value); + if (cleaned.isBlank() + || cleaned.endsWith(".") + || cleaned.endsWith("?") + || cleaned.endsWith("!")) { + return cleaned; + } + return cleaned + "."; + } + + private static String titleCase(String value) { + String cleaned = clean(value); + if (cleaned.isBlank()) { + return ""; + } + return Character.toUpperCase(cleaned.charAt(0)) + cleaned.substring(1); + } + + private static String clean(String value) { + return value == null ? "" : value.replaceAll("\\s+", " ").trim(); + } + + private static Object firstPresent(Map metadata, String... keys) { + for (String key : keys) { + Object value = metadata.get(key); + if (value != null && !value.toString().isBlank()) { + return value; + } + } + return ""; + } + + private static String list(Object value) { + if (!(value instanceof List list)) { + return "[]"; + } + return list.stream() + .filter(java.util.Objects::nonNull) + .map(Object::toString) + .filter(item -> !item.isBlank()) + .toList() + .toString(); + } + private String summary(Map metadata) { var parts = new java.util.ArrayList(); String file = first(metadata.get("files")); @@ -104,4 +286,16 @@ private static String truncate(String content, int maxChars) { } return content.length() <= maxChars ? content : content.substring(0, maxChars); } + + private static String string(Object value) { + return value == null ? "" : value.toString(); + } + + public record AgentCaptionResponse( + String task, + String summary, + String outcome, + List keyActions, + List evidence, + String next) {} } diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/plugin/AgentRawDataPlugin.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/plugin/AgentRawDataPlugin.java index dad4aca2..3dcb2c18 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/plugin/AgentRawDataPlugin.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/plugin/AgentRawDataPlugin.java @@ -50,7 +50,7 @@ public List> processors(RawDataPluginContext context) { return List.of( new AgentTimelineContentProcessor( new AgentTimelineChunker(options.chunking(), options.privacy()), - new AgentCaptionGenerator(), + new AgentCaptionGenerator(context.chatClientRegistry().defaultClient()), new AgentItemExtractionStrategy( context.chatClientRegistry().defaultClient(), context.promptRegistry(), diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGeneratorTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGeneratorTest.java index 08e93394..d18b5bbc 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGeneratorTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGeneratorTest.java @@ -15,9 +15,12 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.openmemind.ai.memory.core.llm.ChatMessage; +import com.openmemind.ai.memory.core.llm.StructuredChatClient; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; class AgentCaptionGeneratorTest { @@ -44,6 +47,81 @@ void shouldBuildDeterministicAgentEpisodeCaptionFromMetadata() { + "(src/payment/calc.ts; npm test payment)"); } + @Test + void shouldGenerateLlmTurnSummaryCaptionWhenClientIsAvailable() { + CapturingChatClient client = + new CapturingChatClient( + new AgentCaptionGenerator.AgentCaptionResponse( + "Fix rawdata-agent identity", + "The turn unified Claude Code and Codex on the shared" + + " `coding-agent` identity while moving projectSlug into" + + " rawdata and item metadata.", + "success", + List.of( + "Updated Claude Code and Codex identity defaults.", + "Projected projectSlug into agent episode metadata."), + List.of( + "Files: identity.py, AgentSegmentFormatter.java", + "Validation: integration tests passed"), + "Use projectSlug later for SessionStart ranking without making it" + + " a memory boundary.")); + + String caption = + new AgentCaptionGenerator(client) + .generate( + "Goal: Fix identity.\nOutcome: success\nEvidence:\n- e1: file_edit", + Map.of( + "goal", + "Fix rawdata-agent identity", + "outcome", + "success", + "files", + List.of("identity.py"), + "commands", + List.of("mvn test"), + "eventIds", + List.of("e1", "e2")), + "English") + .block(); + + assertThat(caption) + .contains( + "Task: Fix rawdata-agent identity.", + "Outcome: Success. The turn unified Claude Code and Codex", + "Key actions:", + "- Updated Claude Code and Codex identity defaults.", + "Evidence:", + "- Files: identity.py, AgentSegmentFormatter.java", + "Next: Use projectSlug later for SessionStart ranking"); + assertThat(client.responseType()) + .isEqualTo(AgentCaptionGenerator.AgentCaptionResponse.class); + assertThat(client.userPrompt()) + .contains("targetLanguage: English", "Goal: Fix identity", "eventIds: [e1, e2]"); + } + + @Test + void shouldFallbackToDeterministicCaptionWhenLlmFails() { + String caption = + new AgentCaptionGenerator(new FailingChatClient()) + .generate( + "content", + Map.of( + "goal", + "Fix payment tests", + "outcome", + "success", + "files", + List.of("src/payment/calc.ts"), + "commands", + List.of("npm test payment"))) + .block(); + + assertThat(caption) + .isEqualTo( + "Agent episode: Fix payment tests -> success " + + "(src/payment/calc.ts; npm test payment)"); + } + @Test void shouldIncludeKeyLifecycleAwareEpisodeSignals() { String caption = @@ -102,4 +180,48 @@ void shouldMentionCompactBoundaryWhenEpisodeEndsAtCompaction() { assertThat(caption) .contains("Continue rawdata-agent implementation", "mvn test", "compact"); } + + private static final class CapturingChatClient implements StructuredChatClient { + + private final AgentCaptionGenerator.AgentCaptionResponse response; + private List messages = List.of(); + private Class responseType; + + private CapturingChatClient(AgentCaptionGenerator.AgentCaptionResponse response) { + this.response = response; + } + + @Override + public Mono call(List messages) { + return Mono.error(new UnsupportedOperationException("not used")); + } + + @Override + public Mono call(List messages, Class responseType) { + this.messages = List.copyOf(messages); + this.responseType = responseType; + return Mono.just(responseType.cast(response)); + } + + private Class responseType() { + return responseType; + } + + private String userPrompt() { + return messages.getLast().content(); + } + } + + private static final class FailingChatClient implements StructuredChatClient { + + @Override + public Mono call(List messages) { + return Mono.error(new UnsupportedOperationException("not used")); + } + + @Override + public Mono call(List messages, Class responseType) { + return Mono.error(new RuntimeException("caption failed")); + } + } } diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/integration/AgentExtractionPipelineIntegrationTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/integration/AgentExtractionPipelineIntegrationTest.java index 46a3f1a1..5efdb851 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/integration/AgentExtractionPipelineIntegrationTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/integration/AgentExtractionPipelineIntegrationTest.java @@ -44,6 +44,7 @@ import com.openmemind.ai.memory.core.store.MemoryStore; import com.openmemind.ai.memory.core.vector.MemoryVector; import com.openmemind.ai.memory.core.vector.VectorSearchResult; +import com.openmemind.ai.memory.plugin.rawdata.agent.caption.AgentCaptionGenerator; import com.openmemind.ai.memory.plugin.rawdata.agent.config.AgentRawDataOptions; import com.openmemind.ai.memory.plugin.rawdata.agent.content.AgentTimelineContent; import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEvent; @@ -141,7 +142,9 @@ void complexSuccessfulEpisodeCanProducePlaybookFromLlm() { extract(fixture, paymentTimeline(paymentEvents())); - assertThat(client.structuredCalls()).isEqualTo(1); + assertThat(client.structuredCalls()).isEqualTo(2); + assertThat(client.captionCalls()).isEqualTo(1); + assertThat(client.itemExtractionCalls()).isEqualTo(1); assertThat(items(fixture)) .anySatisfy( item -> { @@ -501,10 +504,28 @@ private record Fixture(Memory memory, MemoryStore store, RecordingMemoryVector v private static final class ScriptedStructuredChatClient implements StructuredChatClient { private final MemoryItemExtractionResponse response; + private final AgentCaptionGenerator.AgentCaptionResponse captionResponse; private int structuredCalls; + private int captionCalls; + private int itemExtractionCalls; private ScriptedStructuredChatClient(MemoryItemExtractionResponse response) { this.response = response; + this.captionResponse = + new AgentCaptionGenerator.AgentCaptionResponse( + "Fix payment tests", + "The turn investigated payment test failures, changed payment" + + " calculation logic, and validated the fix.", + "success", + List.of( + "Ran npm test payment and captured the rounding mismatch.", + "Edited src/payment/calc.ts.", + "Reran npm test payment successfully."), + List.of( + "Command: npm test payment", + "File: src/payment/calc.ts", + "Validation: npm test payment passed"), + ""); } @Override @@ -513,14 +534,31 @@ public Mono call(List messages) { } @Override + @SuppressWarnings("unchecked") public Mono call(List messages, Class responseType) { structuredCalls++; - return Mono.just(responseType.cast(response)); + if (responseType == AgentCaptionGenerator.AgentCaptionResponse.class) { + captionCalls++; + return Mono.just((T) captionResponse); + } + if (responseType == MemoryItemExtractionResponse.class) { + itemExtractionCalls++; + return Mono.just((T) response); + } + return Mono.empty(); } private int structuredCalls() { return structuredCalls; } + + private int captionCalls() { + return captionCalls; + } + + private int itemExtractionCalls() { + return itemExtractionCalls; + } } private static final class RecordingMemoryVector implements MemoryVector { diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemoryQueryController.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemoryQueryController.java index 0e7a6533..cba43e8d 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemoryQueryController.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemoryQueryController.java @@ -14,9 +14,14 @@ package com.openmemind.ai.memory.server.controller.openapi; import com.openmemind.ai.memory.server.domain.common.SuccessResult; +import com.openmemind.ai.memory.server.domain.memory.request.QueryMemoryItemsRequest; +import com.openmemind.ai.memory.server.domain.memory.request.QueryMemoryRawDataRequest; import com.openmemind.ai.memory.server.domain.memory.request.RetrieveMemoryRequest; +import com.openmemind.ai.memory.server.domain.memory.response.QueryMemoryItemsResponse; +import com.openmemind.ai.memory.server.domain.memory.response.QueryMemoryRawDataResponse; import com.openmemind.ai.memory.server.domain.memory.response.RetrieveMemoryResponse; import com.openmemind.ai.memory.server.service.memory.OpenMemoryApplicationService; +import com.openmemind.ai.memory.server.service.memory.OpenMemoryAssetQueryService; import jakarta.validation.Valid; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -28,9 +33,12 @@ public class OpenMemoryQueryController { private final OpenMemoryApplicationService service; + private final OpenMemoryAssetQueryService assetQueryService; - public OpenMemoryQueryController(OpenMemoryApplicationService service) { + public OpenMemoryQueryController( + OpenMemoryApplicationService service, OpenMemoryAssetQueryService assetQueryService) { this.service = service; + this.assetQueryService = assetQueryService; } @PostMapping("/retrieve") @@ -38,4 +46,16 @@ public SuccessResult retrieve( @Valid @RequestBody RetrieveMemoryRequest request) { return new SuccessResult<>(service.retrieve(request)); } + + @PostMapping("/items/query") + public SuccessResult queryItems( + @Valid @RequestBody QueryMemoryItemsRequest request) { + return new SuccessResult<>(assetQueryService.queryItems(request)); + } + + @PostMapping("/raw-data/query") + public SuccessResult queryRawData( + @Valid @RequestBody QueryMemoryRawDataRequest request) { + return new SuccessResult<>(assetQueryService.queryRawData(request)); + } } diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/item/query/ItemPageQuery.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/item/query/ItemPageQuery.java index ee233997..aa76032f 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/item/query/ItemPageQuery.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/item/query/ItemPageQuery.java @@ -22,10 +22,22 @@ public record ItemPageQuery( String agentId, String scope, String category, + List categories, String type, String rawDataId, + List sourceClients, + List rawDataTypes, + java.time.Instant occurredAtFrom, + java.time.Instant occurredAtTo, List orderBy) { + public ItemPageQuery { + categories = categories == null ? List.of() : List.copyOf(categories); + sourceClients = sourceClients == null ? List.of() : List.copyOf(sourceClients); + rawDataTypes = rawDataTypes == null ? List.of() : List.copyOf(rawDataTypes); + orderBy = orderBy == null ? List.of() : List.copyOf(orderBy); + } + public static ItemPageQuery of( int pageNo, int pageSize, @@ -42,8 +54,41 @@ public static ItemPageQuery of( agentId, scope, category, + List.of(), type, rawDataId, + List.of(), + List.of(), + null, + null, + List.of("observed_at DESC", "created_at DESC", "biz_id DESC")); + } + + public static ItemPageQuery openApi( + int pageNo, + int pageSize, + String userId, + String agentId, + String scope, + List categories, + List sourceClients, + List rawDataTypes, + java.time.Instant occurredAtFrom, + java.time.Instant occurredAtTo) { + return new ItemPageQuery( + pageNo, + pageSize, + userId, + agentId, + scope, + null, + categories, + null, + null, + sourceClients, + rawDataTypes, + occurredAtFrom, + occurredAtTo, List.of("observed_at DESC", "created_at DESC", "biz_id DESC")); } } diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/request/MetadataFilter.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/request/MetadataFilter.java new file mode 100644 index 00000000..78bc7b9b --- /dev/null +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/request/MetadataFilter.java @@ -0,0 +1,47 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.server.domain.memory.request; + +import java.util.List; + +public record MetadataFilter(List all, List any, List not) { + + public MetadataFilter { + all = all == null ? List.of() : List.copyOf(all); + any = any == null ? List.of() : List.copyOf(any); + not = not == null ? List.of() : List.copyOf(not); + } + + public boolean isEmpty() { + return all.isEmpty() && any.isEmpty() && not.isEmpty(); + } + + public com.openmemind.ai.memory.core.retrieval.filter.MetadataFilter toCoreFilter() { + return new com.openmemind.ai.memory.core.retrieval.filter.MetadataFilter( + toCoreConditions(all), toCoreConditions(any), toCoreConditions(not)); + } + + private static List + toCoreConditions(List conditions) { + return conditions.stream() + .map( + condition -> + new com.openmemind.ai.memory.core.retrieval.filter.MetadataFilter + .Condition( + condition.path(), condition.op(), condition.value())) + .toList(); + } + + public record Condition(String path, String op, Object value) {} +} diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/request/QueryMemoryItemsRequest.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/request/QueryMemoryItemsRequest.java new file mode 100644 index 00000000..8d0a1b7a --- /dev/null +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/request/QueryMemoryItemsRequest.java @@ -0,0 +1,45 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.server.domain.memory.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import java.time.Instant; +import java.util.List; + +public record QueryMemoryItemsRequest( + @NotBlank String userId, + @NotBlank String agentId, + String scope, + List categories, + List sourceClients, + List rawDataTypes, + TimeRange timeRange, + MetadataFilter metadataFilter, + @Min(1) @Max(100) Integer limit, + String cursor) { + + public QueryMemoryItemsRequest { + categories = categories == null ? List.of() : List.copyOf(categories); + sourceClients = sourceClients == null ? List.of() : List.copyOf(sourceClients); + rawDataTypes = rawDataTypes == null ? List.of() : List.copyOf(rawDataTypes); + } + + public int effectiveLimit() { + return limit == null ? 20 : limit; + } + + public record TimeRange(String field, Instant from, Instant to) {} +} diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/request/QueryMemoryRawDataRequest.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/request/QueryMemoryRawDataRequest.java new file mode 100644 index 00000000..ae8aa609 --- /dev/null +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/request/QueryMemoryRawDataRequest.java @@ -0,0 +1,57 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.server.domain.memory.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import java.time.Instant; +import java.util.List; + +public record QueryMemoryRawDataRequest( + @NotBlank String userId, + @NotBlank String agentId, + List types, + List sourceClients, + TimeRange timeRange, + MetadataFilter metadataFilter, + IncludeOptions include, + @Min(1) @Max(100) Integer limit, + String cursor) { + + public QueryMemoryRawDataRequest { + types = types == null ? List.of() : List.copyOf(types); + sourceClients = sourceClients == null ? List.of() : List.copyOf(sourceClients); + } + + public int effectiveLimit() { + return limit == null ? 20 : limit; + } + + public IncludeOptions effectiveInclude() { + return include == null ? new IncludeOptions(false, true) : include; + } + + public record TimeRange(String field, Instant from, Instant to) {} + + public record IncludeOptions(Boolean segment, Boolean metadata) { + public boolean includeSegment() { + return Boolean.TRUE.equals(segment); + } + + public boolean includeMetadata() { + return metadata == null || Boolean.TRUE.equals(metadata); + } + } +} diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/request/RetrieveMemoryRequest.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/request/RetrieveMemoryRequest.java index 9a032fe7..87044ee6 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/request/RetrieveMemoryRequest.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/request/RetrieveMemoryRequest.java @@ -16,16 +16,40 @@ import com.openmemind.ai.memory.core.retrieval.RetrievalConfig; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import java.time.Instant; +import java.util.List; public record RetrieveMemoryRequest( @NotBlank String userId, @NotBlank String agentId, @NotBlank String query, @NotNull RetrievalConfig.Strategy strategy, - Boolean trace) { + Boolean trace, + String scope, + List categories, + TimeRange timeRange, + MetadataFilter metadataFilter, + IncludeOptions include) { + + public RetrieveMemoryRequest { + categories = categories == null ? List.of() : List.copyOf(categories); + } public RetrieveMemoryRequest( String userId, String agentId, String query, RetrievalConfig.Strategy strategy) { this(userId, agentId, query, strategy, null); } + + public RetrieveMemoryRequest( + String userId, + String agentId, + String query, + RetrievalConfig.Strategy strategy, + Boolean trace) { + this(userId, agentId, query, strategy, trace, null, List.of(), null, null, null); + } + + public record TimeRange(String field, Instant from, Instant to) {} + + public record IncludeOptions(Boolean rawDataMetadata, Boolean rawDataSegment) {} } diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/response/QueryMemoryItemsResponse.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/response/QueryMemoryItemsResponse.java new file mode 100644 index 00000000..8b99da13 --- /dev/null +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/response/QueryMemoryItemsResponse.java @@ -0,0 +1,44 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.server.domain.memory.response; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +public record QueryMemoryItemsResponse(List items, String nextCursor) { + + public QueryMemoryItemsResponse { + items = items == null ? List.of() : List.copyOf(items); + } + + public record MemoryItemView( + String id, + String text, + String scope, + String category, + String type, + String rawDataId, + String rawDataType, + String sourceClient, + Instant occurredAt, + Instant observedAt, + Instant createdAt, + Map metadata) { + + public MemoryItemView { + metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + } + } +} diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/response/QueryMemoryRawDataResponse.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/response/QueryMemoryRawDataResponse.java new file mode 100644 index 00000000..4807246a --- /dev/null +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/response/QueryMemoryRawDataResponse.java @@ -0,0 +1,42 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.server.domain.memory.response; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +public record QueryMemoryRawDataResponse(List rawData, String nextCursor) { + + public QueryMemoryRawDataResponse { + rawData = rawData == null ? List.of() : List.copyOf(rawData); + } + + public record MemoryRawDataView( + String id, + String type, + String sourceClient, + String caption, + Map metadata, + Map segment, + Instant startTime, + Instant endTime, + Instant createdAt) { + + public MemoryRawDataView { + metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + segment = segment == null ? null : Map.copyOf(segment); + } + } +} diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/response/RetrieveMemoryResponse.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/response/RetrieveMemoryResponse.java index dc496a88..6227d48e 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/response/RetrieveMemoryResponse.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/response/RetrieveMemoryResponse.java @@ -60,5 +60,25 @@ public RetrievedItemView( public record RetrievedInsightView(String id, String text, String tier) {} public record RetrievedRawDataView( - String rawDataId, String caption, double maxScore, List itemIds) {} + String rawDataId, + String caption, + double maxScore, + List itemIds, + String type, + String sourceClient, + Map metadata, + Instant startTime, + Instant endTime, + Instant createdAt) { + + public RetrievedRawDataView( + String rawDataId, String caption, double maxScore, List itemIds) { + this(rawDataId, caption, maxScore, itemIds, null, null, Map.of(), null, null, null); + } + + public RetrievedRawDataView { + itemIds = itemIds == null ? List.of() : List.copyOf(itemIds); + metadata = metadata == null ? Map.of() : Map.copyOf(metadata); + } + } } diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/rawdata/query/RawDataPageQuery.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/rawdata/query/RawDataPageQuery.java index e0afce06..b652686e 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/rawdata/query/RawDataPageQuery.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/rawdata/query/RawDataPageQuery.java @@ -23,8 +23,16 @@ public record RawDataPageQuery( String agentId, Instant startTimeFrom, Instant startTimeTo, + List types, + List sourceClients, List orderBy) { + public RawDataPageQuery { + types = types == null ? List.of() : List.copyOf(types); + sourceClients = sourceClients == null ? List.of() : List.copyOf(sourceClients); + orderBy = orderBy == null ? List.of() : List.copyOf(orderBy); + } + public static RawDataPageQuery of( int pageNo, int pageSize, @@ -39,6 +47,29 @@ public static RawDataPageQuery of( agentId, startTimeFrom, startTimeTo, + List.of(), + List.of(), + List.of("start_time DESC", "created_at DESC")); + } + + public static RawDataPageQuery openApi( + int pageNo, + int pageSize, + String userId, + String agentId, + Instant startTimeFrom, + Instant startTimeTo, + List types, + List sourceClients) { + return new RawDataPageQuery( + pageNo, + pageSize, + userId, + agentId, + startTimeFrom, + startTimeTo, + types, + sourceClients, List.of("start_time DESC", "created_at DESC")); } } diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/mapper/item/AdminItemQueryMapper.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/mapper/item/AdminItemQueryMapper.java index 0cbcc345..04656f78 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/mapper/item/AdminItemQueryMapper.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/mapper/item/AdminItemQueryMapper.java @@ -64,12 +64,32 @@ public PageResponse page(ItemPageQuery query) { if (StringUtils.hasText(query.category())) { wrapper.in(MemoryItemDO::getCategory, categoryFilterValues(query.category())); } + if (!query.categories().isEmpty()) { + wrapper.in( + MemoryItemDO::getCategory, + query.categories().stream() + .flatMap(category -> categoryFilterValues(category).stream()) + .distinct() + .toList()); + } if (StringUtils.hasText(query.type())) { wrapper.eq(MemoryItemDO::getType, query.type()); } if (StringUtils.hasText(query.rawDataId())) { wrapper.eq(MemoryItemDO::getRawDataId, query.rawDataId()); } + if (!query.sourceClients().isEmpty()) { + wrapper.in(MemoryItemDO::getSourceClient, query.sourceClients()); + } + if (!query.rawDataTypes().isEmpty()) { + wrapper.in(MemoryItemDO::getRawDataType, query.rawDataTypes()); + } + if (query.occurredAtFrom() != null) { + wrapper.ge(MemoryItemDO::getOccurredAt, query.occurredAtFrom()); + } + if (query.occurredAtTo() != null) { + wrapper.le(MemoryItemDO::getOccurredAt, query.occurredAtTo()); + } wrapper.orderByDesc( MemoryItemDO::getObservedAt, MemoryItemDO::getCreatedAt, MemoryItemDO::getBizId); Page result = itemMapper.selectPage(page, wrapper); diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/mapper/rawdata/AdminRawDataQueryMapper.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/mapper/rawdata/AdminRawDataQueryMapper.java index 7793a615..96ede291 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/mapper/rawdata/AdminRawDataQueryMapper.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/mapper/rawdata/AdminRawDataQueryMapper.java @@ -66,6 +66,12 @@ public PageResponse page(RawDataPageQuery query) { if (query.startTimeTo() != null) { wrapper.apply("start_time <= {0," + INSTANT_TYPE_HANDLER + "}", query.startTimeTo()); } + if (!query.types().isEmpty()) { + wrapper.in(MemoryRawDataDO::getType, query.types()); + } + if (!query.sourceClients().isEmpty()) { + wrapper.in(MemoryRawDataDO::getSourceClient, query.sourceClients()); + } wrapper.orderByDesc(MemoryRawDataDO::getStartTime, MemoryRawDataDO::getCreatedAt); Page result = rawDataMapper.selectPage(page, wrapper); return new PageResponse<>( diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryApplicationService.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryApplicationService.java index ec281148..1ee3ce4b 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryApplicationService.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryApplicationService.java @@ -19,9 +19,14 @@ import com.openmemind.ai.memory.core.data.MemoryInsight; import com.openmemind.ai.memory.core.data.MemoryItem; import com.openmemind.ai.memory.core.data.MemoryRawData; +import com.openmemind.ai.memory.core.data.enums.MemoryCategory; +import com.openmemind.ai.memory.core.data.enums.MemoryScope; import com.openmemind.ai.memory.core.extraction.ExtractionRequest; import com.openmemind.ai.memory.core.extraction.ExtractionResult; +import com.openmemind.ai.memory.core.retrieval.RetrievalConfig; +import com.openmemind.ai.memory.core.retrieval.RetrievalRequest; import com.openmemind.ai.memory.core.retrieval.RetrievalResult; +import com.openmemind.ai.memory.core.retrieval.query.QueryContext; import com.openmemind.ai.memory.core.retrieval.scoring.ScoredResult; import com.openmemind.ai.memory.core.retrieval.trace.BoundedRetrievalTraceCollector; import com.openmemind.ai.memory.core.retrieval.trace.RetrievalDebugTrace; @@ -38,9 +43,14 @@ import com.openmemind.ai.memory.server.domain.memory.response.RetrieveMemoryResponse; import com.openmemind.ai.memory.server.runtime.MemoryRuntimeManager; import java.time.Duration; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.function.Function; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -170,10 +180,12 @@ private Mono retrievalMono( RetrieveMemoryRequest request, BoundedRetrievalTraceCollector traceCollector) { Mono operation = - memory.retrieve( - DefaultMemoryId.of(request.userId(), request.agentId()), - request.query(), - request.strategy()); + shouldUseStructuredRetrieval(request) + ? memory.retrieve(toRetrievalRequest(request)) + : memory.retrieve( + DefaultMemoryId.of(request.userId(), request.agentId()), + request.query(), + request.strategy()); if (traceCollector == null) { return operation; } @@ -181,6 +193,78 @@ private Mono retrievalMono( context -> RetrievalTraceContext.withCollector(context, traceCollector)); } + private static boolean shouldUseStructuredRetrieval(RetrieveMemoryRequest request) { + return hasText(request.scope()) + || (request.categories() != null && !request.categories().isEmpty()) + || request.timeRange() != null + || request.metadataFilter() != null + || request.include() != null; + } + + private static RetrievalRequest toRetrievalRequest(RetrieveMemoryRequest request) { + Map metadata = new LinkedHashMap<>(); + if (request.timeRange() != null) { + if (request.timeRange().from() != null) { + metadata.put(QueryContext.META_TIME_RANGE_START, request.timeRange().from()); + } + if (request.timeRange().to() != null) { + metadata.put(QueryContext.META_TIME_RANGE_END, request.timeRange().to()); + } + } + if (request.metadataFilter() != null && !request.metadataFilter().isEmpty()) { + metadata.put( + QueryContext.META_METADATA_FILTER, request.metadataFilter().toCoreFilter()); + } + return new RetrievalRequest( + DefaultMemoryId.of(request.userId(), request.agentId()), + request.query(), + List.of(), + config(request.strategy()), + Map.copyOf(metadata), + parseScope(request.scope()), + parseCategories(request.categories())); + } + + private static RetrievalConfig config(RetrievalConfig.Strategy strategy) { + return switch (strategy) { + case SIMPLE -> RetrievalConfig.simple(); + case DEEP -> RetrievalConfig.deep(); + }; + } + + private static MemoryScope parseScope(String scope) { + if (!hasText(scope)) { + return null; + } + return MemoryScope.valueOf(scope.trim().toUpperCase(Locale.ROOT)); + } + + private static Set parseCategories(List categories) { + if (categories == null || categories.isEmpty()) { + return null; + } + Set parsed = + categories.stream() + .filter(OpenMemoryApplicationService::hasText) + .map(OpenMemoryApplicationService::parseCategory) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + return parsed.isEmpty() ? null : parsed; + } + + private static MemoryCategory parseCategory(String category) { + String normalized = category.trim(); + try { + return MemoryCategory.valueOf(normalized.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + return MemoryCategory.byName(normalized.toLowerCase(Locale.ROOT)).orElse(null); + } + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } + private static ExtractionRequest extractionRequest( MemoryId memoryId, ExtractMemoryRequest request) { return ExtractionRequest.of(memoryId, request.rawContent()) @@ -289,7 +373,13 @@ private static RetrieveMemoryResponse toRetrieveResponse( rawData.rawDataId(), rawData.caption(), rawData.maxScore(), - rawData.itemIds())) + rawData.itemIds(), + rawData.type(), + rawData.sourceClient(), + rawData.metadata(), + rawData.startTime(), + rawData.endTime(), + rawData.createdAt())) .toList(), result.evidences() == null ? List.of() : result.evidences(), result.strategy(), diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryAssetQueryService.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryAssetQueryService.java new file mode 100644 index 00000000..c873e1e2 --- /dev/null +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryAssetQueryService.java @@ -0,0 +1,129 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.server.service.memory; + +import com.openmemind.ai.memory.core.retrieval.filter.MetadataFilterMatcher; +import com.openmemind.ai.memory.server.domain.item.query.ItemPageQuery; +import com.openmemind.ai.memory.server.domain.item.view.AdminItemView; +import com.openmemind.ai.memory.server.domain.memory.request.MetadataFilter; +import com.openmemind.ai.memory.server.domain.memory.request.QueryMemoryItemsRequest; +import com.openmemind.ai.memory.server.domain.memory.request.QueryMemoryRawDataRequest; +import com.openmemind.ai.memory.server.domain.memory.response.QueryMemoryItemsResponse; +import com.openmemind.ai.memory.server.domain.memory.response.QueryMemoryRawDataResponse; +import com.openmemind.ai.memory.server.domain.rawdata.query.RawDataPageQuery; +import com.openmemind.ai.memory.server.domain.rawdata.view.AdminRawDataView; +import com.openmemind.ai.memory.server.service.item.ItemQueryService; +import com.openmemind.ai.memory.server.service.rawdata.RawDataQueryService; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Service; + +@Service +public class OpenMemoryAssetQueryService { + + private final ItemQueryService itemQueryService; + private final RawDataQueryService rawDataQueryService; + + public OpenMemoryAssetQueryService( + ItemQueryService itemQueryService, RawDataQueryService rawDataQueryService) { + this.itemQueryService = itemQueryService; + this.rawDataQueryService = rawDataQueryService; + } + + public QueryMemoryItemsResponse queryItems(QueryMemoryItemsRequest request) { + QueryMemoryItemsRequest.TimeRange timeRange = request.timeRange(); + var page = + itemQueryService.listItems( + ItemPageQuery.openApi( + 1, + request.effectiveLimit(), + request.userId(), + request.agentId(), + request.scope(), + request.categories(), + request.sourceClients(), + request.rawDataTypes(), + timeRange == null ? null : timeRange.from(), + timeRange == null ? null : timeRange.to())); + List items = + page.items().stream() + .filter(item -> matchesMetadata(item.metadata(), request.metadataFilter())) + .map(OpenMemoryAssetQueryService::toItemView) + .toList(); + return new QueryMemoryItemsResponse(items, null); + } + + public QueryMemoryRawDataResponse queryRawData(QueryMemoryRawDataRequest request) { + QueryMemoryRawDataRequest.TimeRange timeRange = request.timeRange(); + var page = + rawDataQueryService.listRawData( + RawDataPageQuery.openApi( + 1, + request.effectiveLimit(), + request.userId(), + request.agentId(), + timeRange == null ? null : timeRange.from(), + timeRange == null ? null : timeRange.to(), + request.types(), + request.sourceClients())); + QueryMemoryRawDataRequest.IncludeOptions include = request.effectiveInclude(); + List rawData = + page.items().stream() + .filter(raw -> matchesMetadata(raw.metadata(), request.metadataFilter())) + .map(raw -> toRawDataView(raw, include)) + .toList(); + return new QueryMemoryRawDataResponse(rawData, null); + } + + private static boolean matchesMetadata(Map metadata, MetadataFilter filter) { + return filter == null + || filter.isEmpty() + || MetadataFilterMatcher.matches(metadata, filter.toCoreFilter()); + } + + private static QueryMemoryItemsResponse.MemoryItemView toItemView(AdminItemView item) { + return new QueryMemoryItemsResponse.MemoryItemView( + item.itemId() == null ? null : String.valueOf(item.itemId()), + item.content(), + item.scope(), + item.category(), + item.type(), + item.rawDataId(), + item.rawDataType(), + item.sourceClient(), + item.occurredAt(), + item.observedAt(), + createdAt(item), + item.metadata()); + } + + private static QueryMemoryRawDataResponse.MemoryRawDataView toRawDataView( + AdminRawDataView rawData, QueryMemoryRawDataRequest.IncludeOptions include) { + return new QueryMemoryRawDataResponse.MemoryRawDataView( + rawData.rawDataId(), + rawData.type(), + rawData.sourceClient(), + rawData.caption(), + include.includeMetadata() ? rawData.metadata() : Map.of(), + include.includeSegment() ? rawData.segment() : null, + rawData.startTime(), + rawData.endTime(), + rawData.createdAt()); + } + + private static Instant createdAt(AdminItemView item) { + return item.createdAt(); + } +} diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemoryControllerTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemoryControllerTest.java index f132eec5..1c1cf10c 100644 --- a/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemoryControllerTest.java +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemoryControllerTest.java @@ -27,11 +27,15 @@ import com.openmemind.ai.memory.server.domain.memory.request.RetrieveMemoryRequest; import com.openmemind.ai.memory.server.domain.memory.response.AddMessageResponse; import com.openmemind.ai.memory.server.domain.memory.response.ExtractMemoryResponse; +import com.openmemind.ai.memory.server.domain.memory.response.QueryMemoryItemsResponse; +import com.openmemind.ai.memory.server.domain.memory.response.QueryMemoryRawDataResponse; import com.openmemind.ai.memory.server.domain.memory.response.RetrieveMemoryResponse; import com.openmemind.ai.memory.server.handler.ApiExceptionHandler; import com.openmemind.ai.memory.server.service.memory.OpenMemoryApplicationService; +import com.openmemind.ai.memory.server.service.memory.OpenMemoryAssetQueryService; import java.time.Instant; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; @@ -43,6 +47,8 @@ class OpenMemoryControllerTest { private final StubOpenMemoryApplicationService service = new StubOpenMemoryApplicationService(); + private final StubOpenMemoryAssetQueryService assetQueryService = + new StubOpenMemoryAssetQueryService(); private final JsonMapper objectMapper = JsonUtils.mapper(); private MockMvc mockMvc; @@ -53,7 +59,7 @@ void setUp() { validator.afterPropertiesSet(); this.mockMvc = MockMvcBuilders.standaloneSetup( - new OpenMemoryQueryController(service), + new OpenMemoryQueryController(service, assetQueryService), new OpenMemorySyncController(service), new OpenMemoryAsyncController(service)) .setControllerAdvice(new ApiExceptionHandler()) @@ -288,6 +294,76 @@ void retrieveReturnsRankedMemoryPayload() throws Exception { .andExpect(jsonPath("$.data.rawData[0].rawDataId").value("rd-1")); } + @Test + void queryItemsReturnsStructuredMemoryItems() throws Exception { + mockMvc.perform( + post("/open/v1/memory/items/query") + .contentType(APPLICATION_JSON) + .content( + """ + { + "userId": "u1", + "agentId": "a1", + "scope": "AGENT", + "categories": ["tool", "resolution"], + "timeRange": { + "field": "occurredAt", + "from": "2026-05-01T00:00:00Z", + "to": "2026-05-27T00:00:00Z" + }, + "metadataFilter": { + "all": [ + {"path": "projectSlug", "op": "eq", "value": "memind"} + ] + }, + "limit": 10 + } + """)) + .andExpect(status().isOk()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.data.items[0].id").value("101")) + .andExpect(jsonPath("$.data.items[0].category").value("tool")) + .andExpect(jsonPath("$.data.items[0].metadata.projectSlug").value("memind")) + .andExpect(jsonPath("$.data.nextCursor").doesNotExist()); + + org.assertj.core.api.Assertions.assertThat(assetQueryService.lastItemsRequest).isNotNull(); + } + + @Test + void queryRawDataOmitsSegmentUnlessIncluded() throws Exception { + mockMvc.perform( + post("/open/v1/memory/raw-data/query") + .contentType(APPLICATION_JSON) + .content( + """ + { + "userId": "u1", + "agentId": "a1", + "types": ["agent_timeline"], + "sourceClients": ["claude-code"], + "timeRange": { + "field": "startTime", + "from": "2026-05-01T00:00:00Z" + }, + "include": { + "segment": false, + "metadata": true + }, + "limit": 10 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.data.rawData[0].id").value("rd-1")) + .andExpect(jsonPath("$.data.rawData[0].type").value("agent_timeline")) + .andExpect(jsonPath("$.data.rawData[0].metadata.projectSlug").value("memind")) + .andExpect(jsonPath("$.data.rawData[0].segment").doesNotExist()); + + org.assertj.core.api.Assertions.assertThat(assetQueryService.lastRawDataRequest) + .isNotNull(); + } + private static String validExtractJson() { return """ { @@ -403,4 +479,59 @@ public RetrieveMemoryResponse retrieve(RetrieveMemoryRequest request) { request.query()); } } + + private static final class StubOpenMemoryAssetQueryService extends OpenMemoryAssetQueryService { + + private com.openmemind.ai.memory.server.domain.memory.request.QueryMemoryItemsRequest + lastItemsRequest; + private com.openmemind.ai.memory.server.domain.memory.request.QueryMemoryRawDataRequest + lastRawDataRequest; + + private StubOpenMemoryAssetQueryService() { + super(null, null); + } + + @Override + public QueryMemoryItemsResponse queryItems( + com.openmemind.ai.memory.server.domain.memory.request.QueryMemoryItemsRequest + request) { + this.lastItemsRequest = request; + return new QueryMemoryItemsResponse( + List.of( + new QueryMemoryItemsResponse.MemoryItemView( + "101", + "Run mvn -pl memind-server -am for server tests", + "AGENT", + "tool", + "FACT", + "rd-1", + "agent_timeline", + "claude-code", + Instant.parse("2026-05-24T12:00:00Z"), + Instant.parse("2026-05-24T12:01:00Z"), + Instant.parse("2026-05-24T12:02:00Z"), + Map.of("projectSlug", "memind"))), + null); + } + + @Override + public QueryMemoryRawDataResponse queryRawData( + com.openmemind.ai.memory.server.domain.memory.request.QueryMemoryRawDataRequest + request) { + this.lastRawDataRequest = request; + return new QueryMemoryRawDataResponse( + List.of( + new QueryMemoryRawDataResponse.MemoryRawDataView( + "rd-1", + "agent_timeline", + "claude-code", + "fixed server API", + Map.of("projectSlug", "memind"), + null, + Instant.parse("2026-05-24T12:00:00Z"), + Instant.parse("2026-05-24T12:10:00Z"), + Instant.parse("2026-05-24T12:11:00Z"))), + null); + } + } } diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/service/item/ItemQueryServiceTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/service/item/ItemQueryServiceTest.java index c5c1c30b..9d071053 100644 --- a/memind-server/src/test/java/com/openmemind/ai/memory/server/service/item/ItemQueryServiceTest.java +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/service/item/ItemQueryServiceTest.java @@ -19,9 +19,16 @@ import com.openmemind.ai.memory.server.domain.common.PageResponse; import com.openmemind.ai.memory.server.domain.item.query.ItemPageQuery; import com.openmemind.ai.memory.server.domain.item.view.AdminItemView; +import com.openmemind.ai.memory.server.domain.memory.request.MetadataFilter; +import com.openmemind.ai.memory.server.domain.memory.request.QueryMemoryItemsRequest; import com.openmemind.ai.memory.server.mapper.item.AdminItemQueryMapper; +import com.openmemind.ai.memory.server.mapper.rawdata.AdminRawDataQueryMapper; +import com.openmemind.ai.memory.server.service.memory.OpenMemoryAssetQueryService; +import com.openmemind.ai.memory.server.service.rawdata.RawDataQueryService; +import java.time.Instant; import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Test; @@ -50,6 +57,88 @@ void getItemThrowsWhenResourceIsMissing() { .hasMessageContaining("101"); } + @Test + void openItemQueryMapsAndFiltersStructuredRequest() { + CapturingItemQueryMapper itemQueryMapper = new CapturingItemQueryMapper(); + itemQueryMapper.page = + new PageResponse<>( + 1, + 2, + 2, + List.of( + itemView( + 101L, + "tool", + "claude-code", + Instant.parse("2026-05-24T12:00:00Z"), + Map.of("projectSlug", "memind")), + itemView( + 102L, + "tool", + "claude-code", + Instant.parse("2026-05-24T12:05:00Z"), + Map.of("projectSlug", "other")))); + OpenMemoryAssetQueryService service = + new OpenMemoryAssetQueryService( + new ItemQueryService(itemQueryMapper), + new RawDataQueryService(new CapturingRawDataQueryMapper())); + + var response = + service.queryItems( + new QueryMemoryItemsRequest( + "u1", + "a1", + "AGENT", + List.of("tool"), + List.of("claude-code"), + List.of("agent_timeline"), + new QueryMemoryItemsRequest.TimeRange( + "occurredAt", + Instant.parse("2026-05-01T00:00:00Z"), + Instant.parse("2026-05-27T00:00:00Z")), + new MetadataFilter( + List.of( + new MetadataFilter.Condition( + "projectSlug", "eq", "memind")), + List.of(), + List.of()), + 2, + null)); + + assertThat(itemQueryMapper.lastPageQuery()).isNotNull(); + assertThat(itemQueryMapper.lastPageQuery().pageSize()).isEqualTo(2); + assertThat(itemQueryMapper.lastPageQuery().scope()).isEqualTo("AGENT"); + assertThat(itemQueryMapper.lastPageQuery().categories()).containsExactly("tool"); + assertThat(response.items()).singleElement().extracting("id").isEqualTo("101"); + } + + private static AdminItemView itemView( + Long itemId, + String category, + String sourceClient, + Instant occurredAt, + Map metadata) { + return new AdminItemView( + itemId, + "u1", + "a1", + "u1:a1", + "content-" + itemId, + "AGENT", + category, + "vec-" + itemId, + "rd-" + itemId, + "hash-" + itemId, + occurredAt, + occurredAt, + metadata, + "FACT", + "agent_timeline", + sourceClient, + occurredAt, + occurredAt); + } + private static final class CapturingItemQueryMapper implements AdminItemQueryMapper { private ItemPageQuery lastPageQuery; @@ -57,7 +146,9 @@ private static final class CapturingItemQueryMapper implements AdminItemQueryMap @Override public PageResponse page(ItemPageQuery query) { this.lastPageQuery = query; - return new PageResponse<>(query.pageNo(), query.pageSize(), 0, List.of()); + return page != null + ? page + : new PageResponse<>(query.pageNo(), query.pageSize(), 0, List.of()); } @Override @@ -78,5 +169,33 @@ public List findByRawDataIds(Collection rawDataIds) { private ItemPageQuery lastPageQuery() { return lastPageQuery; } + + private PageResponse page; + } + + private static final class CapturingRawDataQueryMapper implements AdminRawDataQueryMapper { + + @Override + public PageResponse + page(com.openmemind.ai.memory.server.domain.rawdata.query.RawDataPageQuery query) { + return new PageResponse<>(query.pageNo(), query.pageSize(), 0, List.of()); + } + + @Override + public Optional + findByBizId(String rawDataId) { + return Optional.empty(); + } + + @Override + public List + findByBizIds(Collection rawDataIds) { + return List.of(); + } + + @Override + public int logicalDeleteByBizIds(Collection rawDataIds) { + return 0; + } } } diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryApplicationServiceTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryApplicationServiceTest.java index e6b7923a..8231b526 100644 --- a/memind-server/src/test/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryApplicationServiceTest.java +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryApplicationServiceTest.java @@ -41,6 +41,7 @@ import com.openmemind.ai.memory.core.retrieval.RetrievalConfig; import com.openmemind.ai.memory.core.retrieval.RetrievalRequest; import com.openmemind.ai.memory.core.retrieval.RetrievalResult; +import com.openmemind.ai.memory.core.retrieval.query.QueryContext; import com.openmemind.ai.memory.core.retrieval.scoring.ScoredResult; import com.openmemind.ai.memory.core.retrieval.trace.RetrievalFinalTrace; import com.openmemind.ai.memory.core.retrieval.trace.RetrievalTraceContext; @@ -48,6 +49,7 @@ import com.openmemind.ai.memory.server.domain.memory.request.AddMessageRequest; import com.openmemind.ai.memory.server.domain.memory.request.CommitMemoryRequest; import com.openmemind.ai.memory.server.domain.memory.request.ExtractMemoryRequest; +import com.openmemind.ai.memory.server.domain.memory.request.MetadataFilter; import com.openmemind.ai.memory.server.domain.memory.request.RetrieveMemoryRequest; import com.openmemind.ai.memory.server.runtime.MemoryRuntimeManager; import com.openmemind.ai.memory.server.runtime.MemoryRuntimeUnavailableException; @@ -197,6 +199,57 @@ void retrieveMapsRetrievalResult() { assertThat(runtimeManager.currentHandle().inFlightRequests()).hasValue(0); } + @Test + void retrieveUsesStructuredRequestWhenFiltersAreProvided() { + RecordingMemory memory = new RecordingMemory(); + memory.retrieveResult = RetrievalResult.empty("SIMPLE", "project context"); + MemoryRuntimeManager runtimeManager = + new MemoryRuntimeManager( + new RuntimeHandle(memory, MemoryBuildOptions.defaults(), 1)); + OpenMemoryApplicationService service = new OpenMemoryApplicationService(runtimeManager); + + service.retrieve( + new RetrieveMemoryRequest( + "u1", + "a1", + "project context", + RetrievalConfig.Strategy.SIMPLE, + null, + "AGENT", + List.of("directive", "playbook"), + new RetrieveMemoryRequest.TimeRange( + "occurredAt", + Instant.parse("2026-05-01T00:00:00Z"), + Instant.parse("2026-05-27T00:00:00Z")), + new MetadataFilter( + List.of( + new MetadataFilter.Condition( + "projectSlug", "eq", "memind-main")), + List.of(), + List.of()), + new RetrieveMemoryRequest.IncludeOptions(true, true))); + + assertThat(memory.lastRetrievalRequest).isNotNull(); + assertThat(memory.lastRetrievalRequest.memoryId()) + .isEqualTo(DefaultMemoryId.of("u1", "a1")); + assertThat(memory.lastRetrievalRequest.scope()).isEqualTo(MemoryScope.AGENT); + assertThat(memory.lastRetrievalRequest.categories()) + .containsExactlyInAnyOrder(MemoryCategory.DIRECTIVE, MemoryCategory.PLAYBOOK); + assertThat(memory.lastRetrievalRequest.metadata()) + .containsEntry("timeRangeStart", Instant.parse("2026-05-01T00:00:00Z")) + .containsEntry("timeRangeEnd", Instant.parse("2026-05-27T00:00:00Z")) + .containsKey(QueryContext.META_METADATA_FILTER); + assertThat(memory.lastRetrievalRequest.metadata().get(QueryContext.META_METADATA_FILTER)) + .isEqualTo( + new com.openmemind.ai.memory.core.retrieval.filter.MetadataFilter( + List.of( + new com.openmemind.ai.memory.core.retrieval.filter + .MetadataFilter.Condition( + "projectSlug", "eq", "memind-main")), + List.of(), + List.of())); + } + @Test void retrieveResponseIncludesStatusFromResult() { RecordingMemory memory = new RecordingMemory(); @@ -357,6 +410,7 @@ private static final class RecordingMemory implements Memory { private final CountDownLatch addMessageInvoked = new CountDownLatch(1); private final CountDownLatch commitInvoked = new CountDownLatch(1); private RetrievalResult retrieveResult; + private RetrievalRequest lastRetrievalRequest; private boolean recordTrace; @Override @@ -439,7 +493,9 @@ public Mono retrieve( @Override public Mono retrieve(RetrievalRequest request) { - throw new UnsupportedOperationException(); + this.lastMemoryId = request.memoryId(); + this.lastRetrievalRequest = request; + return Mono.just(retrieveResult); } @Override From 42c637a0925e19d43f33d2b5f06c3e930e69cea2 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Wed, 27 May 2026 23:12:14 +0800 Subject: [PATCH 29/54] feat: inject agent session context on startup --- memind-integrations/claude-code/README.md | 43 +++- .../claude-code/scripts/lib/client.py | 75 +++++++ .../claude-code/scripts/lib/config.py | 10 + .../scripts/lib/session_context.py | 195 ++++++++++++++++++ .../claude-code/scripts/session_start.py | 49 ++++- memind-integrations/claude-code/settings.json | 4 + .../claude-code/tests/test_client.py | 74 +++++++ .../claude-code/tests/test_config.py | 17 +- .../claude-code/tests/test_hooks.py | 66 ++++++ .../claude-code/tests/test_session_context.py | 132 ++++++++++++ memind-integrations/codex/README.md | 44 +++- memind-integrations/codex/install.sh | 4 + .../codex/scripts/lib/client.py | 75 +++++++ .../codex/scripts/lib/config.py | 8 + .../codex/scripts/lib/session_context.py | 195 ++++++++++++++++++ .../codex/scripts/session_start.py | 50 ++++- memind-integrations/codex/settings.json | 4 + .../codex/tests/test_client.py | 73 +++++++ .../codex/tests/test_config.py | 12 ++ memind-integrations/codex/tests/test_hooks.py | 66 ++++++ .../codex/tests/test_installer.py | 3 + .../codex/tests/test_session_context.py | 96 +++++++++ 22 files changed, 1277 insertions(+), 18 deletions(-) create mode 100644 memind-integrations/claude-code/scripts/lib/session_context.py create mode 100644 memind-integrations/claude-code/tests/test_session_context.py create mode 100644 memind-integrations/codex/scripts/lib/session_context.py create mode 100644 memind-integrations/codex/tests/test_session_context.py diff --git a/memind-integrations/claude-code/README.md b/memind-integrations/claude-code/README.md index 24f3ce54..d4bdc9d5 100644 --- a/memind-integrations/claude-code/README.md +++ b/memind-integrations/claude-code/README.md @@ -102,7 +102,7 @@ The installed hooks are: | Claude Code event | Script | Timeout | Purpose | | --- | --- | ---: | --- | -| `SessionStart` | `scripts/session_start.py` | 5s | Health check, replay at most one failed retry payload, and clean old state. | +| `SessionStart` | `scripts/session_start.py` | 5s | Health check, replay at most one failed retry payload, clean old state, and inject project continuity context when available. | | `UserPromptSubmit` | `scripts/retrieve.py` | 12s | Buffer the user prompt event and retrieve relevant Memind context. | | `PreToolUse` | `scripts/pre_tool_use.py` | 5s | Buffer a redacted tool-start event in local session state. | | `PostToolUse` | `scripts/post_tool_use.py` | 5s | Buffer a redacted tool-result event in local session state. | @@ -150,11 +150,15 @@ Settings are loaded in this order: | `agentId` | `coding-agent` | Shared Memind agent identity. Use the same value from Claude Code, Codex, and API clients to share one coding-agent memory space. | | `sourceClient` | `claude-code` | Source marker stored with Memind data. | | `autoRetrieve` | `true` | Enables prompt-time memory retrieval. | +| `autoSessionContext` | `true` | Enables SessionStart project continuity context injection. | | `autoIngestAgentTimeline` | `true` | Enables user prompt, tool/result, assistant message, and stop event buffering plus `agent_timeline` rawdata flush. | | `retrieveStrategy` | `SIMPLE` | Memind retrieval strategy. | | `retrieveMaxEntries` | `8` | Maximum formatted memory entries injected into Claude Code. | | `retrieveMaxChars` | `6000` | Maximum injected context characters. | | `retrieveContextTurns` | `0` | Number of recent transcript turns to include in the retrieval query. | +| `sessionContextRecentSessions` | `3` | Maximum recent `agent_timeline` captions shown at SessionStart. | +| `sessionContextMaxItems` | `6` | Maximum items fetched for each SessionStart context section. | +| `sessionContextMaxChars` | `6000` | Maximum SessionStart context characters. | | `ingestRetrySpool` | `true` | Enables file-backed retry for failed extraction payloads. | | `debug` | `false` | Writes debug logs to `~/.memind/claude-code.log`. | @@ -168,15 +172,17 @@ export MEMIND_API_TOKEN=... export MEMIND_USER_ID=local__alice export MEMIND_AGENT_ID=coding-agent export MEMIND_SOURCE_CLIENT=claude-code +export MEMIND_AUTO_SESSION_CONTEXT=true export MEMIND_AUTO_INGEST_AGENT_TIMELINE=true +export MEMIND_SESSION_CONTEXT_MAX_CHARS=6000 export MEMIND_RETRIEVE_CONTEXT_TURNS=0 export MEMIND_DEBUG=true ``` Additional environment variables include `MEMIND_AUTO_RETRIEVE`, `MEMIND_RETRIEVE_STRATEGY`, `MEMIND_RETRIEVE_MAX_ENTRIES`, `MEMIND_RETRIEVE_MAX_CHARS`, `MEMIND_STATE_MAX_AGE_DAYS`, -`MEMIND_INGEST_RETRY_SPOOL`, `MEMIND_INGEST_RETRY_MAX_FILES`, and -`MEMIND_INGEST_RETRY_MAX_AGE_DAYS`. +`MEMIND_SESSION_CONTEXT_RECENT_SESSIONS`, `MEMIND_SESSION_CONTEXT_MAX_ITEMS`, +`MEMIND_INGEST_RETRY_SPOOL`, `MEMIND_INGEST_RETRY_MAX_FILES`, and `MEMIND_INGEST_RETRY_MAX_AGE_DAYS`. ## Identity Model @@ -193,6 +199,37 @@ remote URL when available, otherwise the local project path. Project metadata su future context compilation without creating separate Memind core project or session entities. `sessionId`, `agentTurnId`, `timelineId`, and per-event turn metadata are also stored only inside raw content and item metadata. +## SessionStart Context + +When `autoSessionContext = true`, the `SessionStart` hook reads existing Memind data for the current `userId`, +`agentId`, and project `metadata.projectSlug`. It does not write rawdata and does not trigger memory extraction. + +The injected context is compiled from generic OpenAPI query results: + +```text + +Memind project memory. Use only when directly helpful. Prefer explicit user instructions and repository files over memory if they conflict. + +## Continue From +- [rawdata:rd-1] Previous turn summary from agent_timeline caption. + +## Must Follow +- [item:101 directive] Project or agent instruction extracted from previous work. + +## Watch Outs +- [item:102 resolution] Previously solved issue or failure pattern. + +## Reusable Playbooks +- [item:103 playbook] Repeatable workflow for this project. + +## Useful Facts +- [item:104 event] Project fact useful for continuing work. + +``` + +This project-continuity context is separate from prompt-time retrieval. It helps a new Claude Code session know what +recently happened in this project before the first user prompt is handled. + ## Retrieval Behavior Retrieval runs before each user prompt when `autoRetrieve = true`. diff --git a/memind-integrations/claude-code/scripts/lib/client.py b/memind-integrations/claude-code/scripts/lib/client.py index 4ddd6081..f0d655db 100644 --- a/memind-integrations/claude-code/scripts/lib/client.py +++ b/memind-integrations/claude-code/scripts/lib/client.py @@ -76,3 +76,78 @@ def retrieve(self, user_id, agent_id, query, strategy="SIMPLE", trace=False): strategy=strategy, trace=trace, ) + + def query_items( + self, + user_id, + agent_id, + scope=None, + categories=None, + source_clients=None, + raw_data_types=None, + time_range=None, + metadata_filter=None, + limit=None, + cursor=None, + ): + from memind import MemindClient as OfficialMemindClient + from memind import QueryMemoryItemsRequest + + request = QueryMemoryItemsRequest( + user_id=user_id, + agent_id=agent_id, + scope=scope, + categories=categories, + source_clients=source_clients, + raw_data_types=raw_data_types, + time_range=time_range, + metadata_filter=metadata_filter, + limit=limit, + cursor=cursor, + ) + with OfficialMemindClient( + base_url=self.base_url, + api_token=self.token, + timeout=self.timeout, + max_retries=self.max_retries, + ) as client: + return client.memory.query_items(request) + + def query_raw_data( + self, + user_id, + agent_id, + types=None, + source_clients=None, + time_range=None, + metadata_filter=None, + include=None, + limit=None, + cursor=None, + ): + from memind import MemindClient as OfficialMemindClient + from memind import QueryMemoryRawDataRequest, RawDataQueryIncludeOptions + + include_options = ( + RawDataQueryIncludeOptions(**include) + if isinstance(include, dict) + else include + ) + request = QueryMemoryRawDataRequest( + user_id=user_id, + agent_id=agent_id, + types=types, + source_clients=source_clients, + time_range=time_range, + metadata_filter=metadata_filter, + include=include_options, + limit=limit, + cursor=cursor, + ) + with OfficialMemindClient( + base_url=self.base_url, + api_token=self.token, + timeout=self.timeout, + max_retries=self.max_retries, + ) as client: + return client.memory.query_raw_data(request) diff --git a/memind-integrations/claude-code/scripts/lib/config.py b/memind-integrations/claude-code/scripts/lib/config.py index 42aa9fb2..b93c7b76 100644 --- a/memind-integrations/claude-code/scripts/lib/config.py +++ b/memind-integrations/claude-code/scripts/lib/config.py @@ -23,12 +23,16 @@ "agentId": "coding-agent", "sourceClient": "claude-code", "autoRetrieve": True, + "autoSessionContext": True, "autoIngestAgentTimeline": True, "retrieveStrategy": "SIMPLE", "retrieveMaxEntries": 8, "retrieveMaxChars": 6000, "retrievePromptPreamble": "Relevant memories from Memind. Use only when directly helpful:", "retrieveContextTurns": 0, + "sessionContextRecentSessions": 3, + "sessionContextMaxItems": 6, + "sessionContextMaxChars": 6000, "stateMaxAgeDays": 14, "ingestRetrySpool": True, "ingestRetryMaxFiles": 20, @@ -43,9 +47,15 @@ "MEMIND_AGENT_ID": ("agentId", str), "MEMIND_SOURCE_CLIENT": ("sourceClient", str), "MEMIND_AUTO_RETRIEVE": ("autoRetrieve", "bool"), + "MEMIND_AUTO_SESSION_CONTEXT": ("autoSessionContext", "bool"), "MEMIND_AUTO_INGEST_AGENT_TIMELINE": ("autoIngestAgentTimeline", "bool"), "MEMIND_RETRIEVE_STRATEGY": ("retrieveStrategy", str), + "MEMIND_RETRIEVE_MAX_ENTRIES": ("retrieveMaxEntries", "int"), + "MEMIND_RETRIEVE_MAX_CHARS": ("retrieveMaxChars", "int"), "MEMIND_RETRIEVE_CONTEXT_TURNS": ("retrieveContextTurns", "int_allow_zero"), + "MEMIND_SESSION_CONTEXT_RECENT_SESSIONS": ("sessionContextRecentSessions", "int"), + "MEMIND_SESSION_CONTEXT_MAX_ITEMS": ("sessionContextMaxItems", "int"), + "MEMIND_SESSION_CONTEXT_MAX_CHARS": ("sessionContextMaxChars", "int"), "MEMIND_STATE_MAX_AGE_DAYS": ("stateMaxAgeDays", "int"), "MEMIND_INGEST_RETRY_SPOOL": ("ingestRetrySpool", "bool"), "MEMIND_INGEST_RETRY_MAX_FILES": ("ingestRetryMaxFiles", "int"), diff --git a/memind-integrations/claude-code/scripts/lib/session_context.py b/memind-integrations/claude-code/scripts/lib/session_context.py new file mode 100644 index 00000000..ff8298b3 --- /dev/null +++ b/memind-integrations/claude-code/scripts/lib/session_context.py @@ -0,0 +1,195 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import html + +DEFAULT_RECENT_SESSIONS = 3 +DEFAULT_MAX_ITEMS = 6 +DEFAULT_MAX_CHARS = 6000 + +SECTION_ORDER = [ + ("recentRawData", "## Continue From"), + ("directive", "## Must Follow"), + ("watchOut", "## Watch Outs"), + ("playbook", "## Reusable Playbooks"), + ("fact", "## Useful Facts"), +] + + +def project_metadata_filter(project_slug): + return {"all": [{"path": "projectSlug", "op": "eq", "value": project_slug}]} + + +def build_session_context(client, identity, project_slug, config): + metadata_filter = project_metadata_filter(project_slug) + recent_limit = int(config.get("sessionContextRecentSessions", DEFAULT_RECENT_SESSIONS)) + max_items = int(config.get("sessionContextMaxItems", DEFAULT_MAX_ITEMS)) + + raw_data = client.query_raw_data( + identity["userId"], + identity["agentId"], + types=["agent_timeline"], + metadata_filter=metadata_filter, + include={"metadata": True, "segment": False}, + limit=recent_limit, + ) + recent_raw_data = [_raw_data_entry(raw) for raw in getattr(raw_data, "raw_data", [])] + recent_raw_data = [entry for entry in recent_raw_data if entry.get("caption")] + + directives = _query_items( + client, + identity, + ["directive"], + metadata_filter, + max_items, + ) + watch_outs = _query_items( + client, + identity, + ["resolution"], + metadata_filter, + max_items, + ) + playbooks = _query_items( + client, + identity, + ["playbook"], + metadata_filter, + max_items, + ) + facts = _query_items( + client, + identity, + ["event", "profile", "behavior", "tool"], + metadata_filter, + max_items, + ) + + return { + "projectSlug": project_slug, + "recentRawData": recent_raw_data, + "items": { + "directive": directives, + "watchOut": watch_outs, + "playbook": playbooks, + "fact": facts, + }, + } + + +def render_session_context(context, config): + project_slug = context.get("projectSlug") or "unknown" + max_chars = int(config.get("sessionContextMaxChars", DEFAULT_MAX_CHARS)) + header = f'' + preamble = ( + "Memind project memory. Use only when directly helpful. Prefer explicit " + "user instructions and repository files over memory if they conflict." + ) + footer = "" + lines = [header, preamble] + + for key, title in SECTION_ORDER: + entries = _section_entries(context, key) + if not entries: + continue + lines.append("") + lines.append(title) + for entry in entries: + lines.append(_render_entry(key, entry)) + + if len(lines) <= 2: + return "" + + full = "\n".join(lines + [footer]) + if len(full) <= max_chars: + return full + + return _truncate_lines(lines, footer, max_chars) + + +def _query_items(client, identity, categories, metadata_filter, limit): + response = client.query_items( + identity["userId"], + identity["agentId"], + categories=categories, + raw_data_types=["agent_timeline"], + metadata_filter=metadata_filter, + limit=limit, + ) + return [_item_entry(item) for item in getattr(response, "items", []) if _text(item)] + + +def _raw_data_entry(raw): + return { + "id": _field(raw, "id"), + "caption": _field(raw, "caption"), + "createdAt": _field(raw, "created_at"), + "metadata": _field(raw, "metadata") or {}, + } + + +def _item_entry(item): + return { + "id": _field(item, "id"), + "text": _text(item), + "category": (_field(item, "category") or "").lower(), + "createdAt": _field(item, "created_at"), + "metadata": _field(item, "metadata") or {}, + } + + +def _section_entries(context, key): + if key == "recentRawData": + return context.get("recentRawData") or [] + return (context.get("items") or {}).get(key) or [] + + +def _render_entry(section_key, entry): + if section_key == "recentRawData": + return f"- [rawdata:{entry.get('id')}] {_clean(entry.get('caption'))}" + category = entry.get("category") or "memory" + return f"- [item:{entry.get('id')} {category}] {_clean(entry.get('text'))}" + + +def _truncate_lines(lines, footer, max_chars): + notice = "\n[truncated]\n" + budget = max(0, max_chars - len(notice) - len(footer)) + selected = [] + used = 0 + for line in lines: + addition = len(line) + (1 if selected else 0) + if used + addition > budget: + break + selected.append(line) + used += addition + if len(selected) <= 2: + base = "\n".join(lines[:2]) + available = max(0, budget - len(base)) + base = base[:available] + return f"{base}{notice}{footer}"[:max_chars] + return f"{chr(10).join(selected)}{notice}{footer}"[:max_chars] + + +def _field(value, name): + if isinstance(value, dict): + return value.get(name) + return getattr(value, name, None) + + +def _text(item): + return _field(item, "text") + + +def _clean(value): + return " ".join(str(value or "").split()) diff --git a/memind-integrations/claude-code/scripts/session_start.py b/memind-integrations/claude-code/scripts/session_start.py index 7daa3ec8..f94acdee 100644 --- a/memind-integrations/claude-code/scripts/session_start.py +++ b/memind-integrations/claude-code/scripts/session_start.py @@ -25,8 +25,10 @@ from ingest import retry_root, state_root from lib.client import MemindClient from lib.config import load_config +from lib.identity import project_slug, resolve_identity from lib.logging_utils import debug_log from lib.retry import RetrySpool +from lib.session_context import build_session_context, render_session_context from lib.state import SessionStateStore @@ -43,7 +45,31 @@ def _tcp_check(url, timeout=1): return True -async def _run_session_start_async(config): +def _base_output(): + return {"continue": True, "suppressOutput": True} + + +def _context_output(client, config, hook_input): + if not config.get("autoSessionContext", True): + return None + cwd = hook_input.get("cwd") or os.getcwd() + identity = resolve_identity(config, hook_input) + slug = project_slug(cwd) + context = build_session_context(client, identity, slug, config) + rendered = render_session_context(context, config) + if not rendered: + return None + return { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": rendered, + } + } + + +async def _run_session_start_async(config, hook_input=None): + hook_input = hook_input or {} + output = _base_output() try: client = MemindClient(config["memindApiUrl"], config.get("memindApiToken"), timeout=2, max_retries=0) try: @@ -89,21 +115,34 @@ async def _run_session_start_async(config): SessionStateStore(state_root()).cleanup(int(config.get("stateMaxAgeDays", 14))) except Exception as exc: debug_log(config, "state_cleanup_failed", {"error": str(exc)}) + try: + context_output = _context_output(client, config, hook_input) + if context_output: + output.update(context_output) + except Exception as exc: + debug_log(config, "session_context_failed", {"error": str(exc)}) except Exception as exc: debug_log(config, "session_start_health_failed", {"error": str(exc)}) + return output -def run_session_start(config): - asyncio.run(_run_session_start_async(config)) +def run_session_start(config, hook_input=None): + return asyncio.run(_run_session_start_async(config, hook_input)) def main(): config = load_config() + hook_input = {} + try: + hook_input = json.loads(sys.stdin.read() or "{}") + except Exception: + hook_input = {} try: - run_session_start(config) + output = run_session_start(config, hook_input) except Exception as exc: debug_log(config, "session_start_health_failed", {"error": str(exc)}) - print(json.dumps({"continue": True, "suppressOutput": True})) + output = _base_output() + print(json.dumps(output)) if __name__ == "__main__": diff --git a/memind-integrations/claude-code/settings.json b/memind-integrations/claude-code/settings.json index 7abc29df..1a477e24 100644 --- a/memind-integrations/claude-code/settings.json +++ b/memind-integrations/claude-code/settings.json @@ -5,12 +5,16 @@ "agentId": "coding-agent", "sourceClient": "claude-code", "autoRetrieve": true, + "autoSessionContext": true, "autoIngestAgentTimeline": true, "retrieveStrategy": "SIMPLE", "retrieveMaxEntries": 8, "retrieveMaxChars": 6000, "retrievePromptPreamble": "Relevant memories from Memind. Use only when directly helpful:", "retrieveContextTurns": 0, + "sessionContextRecentSessions": 3, + "sessionContextMaxItems": 6, + "sessionContextMaxChars": 6000, "stateMaxAgeDays": 14, "ingestRetrySpool": true, "ingestRetryMaxFiles": 20, diff --git a/memind-integrations/claude-code/tests/test_client.py b/memind-integrations/claude-code/tests/test_client.py index 6e2fcdd9..50777a47 100644 --- a/memind-integrations/claude-code/tests/test_client.py +++ b/memind-integrations/claude-code/tests/test_client.py @@ -73,6 +73,14 @@ def retrieve(self, **kwargs): } ) + def query_items(self, request): + self.calls.append(("query_items", request)) + return SimpleNamespace(items=[SimpleNamespace(id="it-1", text="Use mvn test")]) + + def query_raw_data(self, request): + self.calls.append(("query_raw_data", request)) + return SimpleNamespace(raw_data=[SimpleNamespace(id="rd-1", caption="Fixed retry replay")]) + class _FakeSyncMemindClient: instances = [] @@ -101,11 +109,45 @@ def __new__(cls, value): return str.__new__(cls, value) +class _MetadataCondition: + def __init__(self, **kwargs): + self.path = kwargs.get("path") + self.op = kwargs.get("op") + self.value = kwargs.get("value") + + +class _MetadataFilter: + def __init__(self, all=None, **kwargs): + self.all = [_MetadataCondition(**item) for item in (all or [])] + + +class _RawDataQueryIncludeOptions: + def __init__(self, segment=None, metadata=None): + self.segment = segment + self.metadata = metadata + + +class _QueryMemoryItemsRequest: + def __init__(self, metadata_filter=None, **kwargs): + self.__dict__.update(kwargs) + self.metadata_filter = _MetadataFilter(**metadata_filter) if isinstance(metadata_filter, dict) else metadata_filter + + +class _QueryMemoryRawDataRequest: + def __init__(self, metadata_filter=None, include=None, **kwargs): + self.__dict__.update(kwargs) + self.metadata_filter = _MetadataFilter(**metadata_filter) if isinstance(metadata_filter, dict) else metadata_filter + self.include = include + + def _fake_memind_module(): module = types.ModuleType("memind") module.AsyncMemindClient = _FakeAsyncMemindClient module.MemindClient = _FakeSyncMemindClient module.Strategy = _Strategy + module.QueryMemoryItemsRequest = _QueryMemoryItemsRequest + module.QueryMemoryRawDataRequest = _QueryMemoryRawDataRequest + module.RawDataQueryIncludeOptions = _RawDataQueryIncludeOptions return module @@ -163,6 +205,38 @@ def test_retrieve_uses_official_sync_client_and_returns_model(self): self.assertEqual(retrieve_call["strategy"], "SIMPLE") self.assertFalse(retrieve_call["trace"]) + def test_query_wrappers_use_official_sync_query_models(self): + with mock.patch.dict(sys.modules, {"memind": _fake_memind_module()}): + MemindClient = _load_client_class() + client = MemindClient("http://127.0.0.1:8366", timeout=12, max_retries=0) + items = client.query_items( + "u", + "a", + categories=["directive"], + raw_data_types=["agent_timeline"], + metadata_filter={"all": [{"path": "projectSlug", "op": "eq", "value": "memind"}]}, + limit=5, + ) + raw_data = client.query_raw_data( + "u", + "a", + types=["agent_timeline"], + metadata_filter={"all": [{"path": "projectSlug", "op": "eq", "value": "memind"}]}, + include={"metadata": True, "segment": False}, + limit=3, + ) + + self.assertEqual(items.items[0].id, "it-1") + self.assertEqual(raw_data.raw_data[0].id, "rd-1") + self.assertEqual(len(_FakeSyncMemindClient.instances), 2) + item_request = _FakeSyncMemindClient.instances[0].memory.calls[0][1] + raw_data_request = _FakeSyncMemindClient.instances[1].memory.calls[0][1] + self.assertEqual(item_request.user_id, "u") + self.assertEqual(item_request.categories, ["directive"]) + self.assertEqual(item_request.metadata_filter.all[0].path, "projectSlug") + self.assertEqual(raw_data_request.types, ["agent_timeline"]) + self.assertFalse(raw_data_request.include.segment) + async def test_missing_official_client_import_propagates_to_hook_fail_open_boundary(self): with mock.patch.dict(sys.modules, {"memind": None}): MemindClient = _load_client_class() diff --git a/memind-integrations/claude-code/tests/test_config.py b/memind-integrations/claude-code/tests/test_config.py index 774e0f9e..bf044489 100644 --- a/memind-integrations/claude-code/tests/test_config.py +++ b/memind-integrations/claude-code/tests/test_config.py @@ -44,6 +44,10 @@ def test_defaults_match_spec(self): self.assertEqual(DEFAULT_SETTINGS["agentId"], "coding-agent") self.assertEqual(DEFAULT_SETTINGS["retrieveContextTurns"], 0) self.assertEqual(DEFAULT_SETTINGS["sourceClient"], "claude-code") + self.assertTrue(DEFAULT_SETTINGS["autoSessionContext"]) + self.assertEqual(DEFAULT_SETTINGS["sessionContextRecentSessions"], 3) + self.assertEqual(DEFAULT_SETTINGS["sessionContextMaxItems"], 6) + self.assertEqual(DEFAULT_SETTINGS["sessionContextMaxChars"], 6000) self.assertTrue(DEFAULT_SETTINGS["autoIngestAgentTimeline"]) self.assertNotIn("agentIdMode", DEFAULT_SETTINGS) self.assertNotIn("autoIngest", DEFAULT_SETTINGS) @@ -58,6 +62,12 @@ def test_environment_overrides(self): env = { "MEMIND_API_URL": "http://memind.example", "MEMIND_AUTO_RETRIEVE": "false", + "MEMIND_AUTO_SESSION_CONTEXT": "false", + "MEMIND_SESSION_CONTEXT_RECENT_SESSIONS": "4", + "MEMIND_SESSION_CONTEXT_MAX_ITEMS": "5", + "MEMIND_SESSION_CONTEXT_MAX_CHARS": "3000", + "MEMIND_RETRIEVE_MAX_ENTRIES": "9", + "MEMIND_RETRIEVE_MAX_CHARS": "7000", "MEMIND_AUTO_INGEST_AGENT_TIMELINE": "false", "MEMIND_STATE_MAX_AGE_DAYS": "30", } @@ -65,11 +75,16 @@ def test_environment_overrides(self): config = load_config(plugin_root=plugin_root, user_config_path=plugin_root / "missing.json") self.assertEqual(config["memindApiUrl"], "http://memind.example") self.assertFalse(config["autoRetrieve"]) + self.assertFalse(config["autoSessionContext"]) + self.assertEqual(config["sessionContextRecentSessions"], 4) + self.assertEqual(config["sessionContextMaxItems"], 5) + self.assertEqual(config["sessionContextMaxChars"], 3000) + self.assertEqual(config["retrieveMaxEntries"], 9) + self.assertEqual(config["retrieveMaxChars"], 7000) self.assertFalse(config["autoIngestAgentTimeline"]) self.assertNotIn("agentIdMode", config) self.assertNotIn("ingestionRoles", config) self.assertEqual(config["stateMaxAgeDays"], 30) - self.assertEqual(config["retrieveMaxEntries"], 3) if __name__ == "__main__": diff --git a/memind-integrations/claude-code/tests/test_hooks.py b/memind-integrations/claude-code/tests/test_hooks.py index fd902b32..2695d488 100644 --- a/memind-integrations/claude-code/tests/test_hooks.py +++ b/memind-integrations/claude-code/tests/test_hooks.py @@ -691,6 +691,72 @@ def test_session_start_replays_agent_timeline_payload_and_clears_event_ids(self) self.assertEqual(list(retry_dir.glob("*.json")), []) client.extract.assert_awaited_once() + def test_session_start_injects_project_context_when_available(self): + sys.path.insert(0, str(ROOT / "scripts")) + import session_start + + config = { + "memindApiUrl": "http://127.0.0.1:8366", + "memindApiToken": None, + "sourceClient": "claude-code", + "userId": "u", + "agentId": "a", + "autoSessionContext": True, + "sessionContextRecentSessions": 1, + "sessionContextMaxItems": 3, + "sessionContextMaxChars": 4000, + "ingestRetryMaxFiles": 20, + "ingestRetryMaxAgeDays": 7, + "stateMaxAgeDays": 14, + "debug": False, + } + + with tempfile.TemporaryDirectory() as tmp: + retry_dir = Path(tmp) / "retry" + state_dir = Path(tmp) / "state" + with mock.patch.object(session_start, "retry_root", return_value=retry_dir): + with mock.patch.object(session_start, "state_root", return_value=state_dir): + with mock.patch.object(session_start, "project_slug", return_value="memind-main"): + with mock.patch.object(session_start, "MemindClient") as client_cls: + health_client = client_cls.return_value + health_client.health = mock.AsyncMock(return_value=types.SimpleNamespace(status="UP")) + health_client.query_raw_data.return_value = types.SimpleNamespace( + raw_data=[ + types.SimpleNamespace( + id="rd-1", + caption="Implemented SessionStart context.", + metadata={}, + ) + ] + ) + health_client.query_items.side_effect = [ + types.SimpleNamespace( + items=[ + types.SimpleNamespace( + id="it-1", + category="directive", + text="Keep userId and agentId stable.", + ) + ] + ), + types.SimpleNamespace(items=[]), + types.SimpleNamespace(items=[]), + types.SimpleNamespace(items=[]), + ] + output = session_start.run_session_start( + config, + {"cwd": tmp, "session_id": "s1"}, + ) + + self.assertIn("hookSpecificOutput", output) + hook_output = output["hookSpecificOutput"] + self.assertEqual(hook_output["hookEventName"], "SessionStart") + self.assertIn("## Continue From", hook_output["additionalContext"]) + self.assertIn("[rawdata:rd-1] Implemented SessionStart context.", hook_output["additionalContext"]) + self.assertIn("[item:it-1 directive] Keep userId and agentId stable.", hook_output["additionalContext"]) + query_raw_call = health_client.query_raw_data.call_args.kwargs + self.assertEqual(query_raw_call["metadata_filter"]["all"][0]["value"], "memind-main") + def test_session_start_recovers_orphaned_claims_before_replay(self): sys.path.insert(0, str(ROOT / "scripts")) import session_start diff --git a/memind-integrations/claude-code/tests/test_session_context.py b/memind-integrations/claude-code/tests/test_session_context.py new file mode 100644 index 00000000..b47ad585 --- /dev/null +++ b/memind-integrations/claude-code/tests/test_session_context.py @@ -0,0 +1,132 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import sys +import types +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +from scripts.lib.session_context import build_session_context, render_session_context + + +class FakeClient: + def __init__(self): + self.raw_data_calls = [] + self.item_calls = [] + + def query_raw_data(self, user_id, agent_id, **kwargs): + self.raw_data_calls.append((user_id, agent_id, kwargs)) + return types.SimpleNamespace( + raw_data=[ + types.SimpleNamespace( + id="rd-1", + caption="Implemented generic memory query APIs and all clients.", + metadata={"projectSlug": "memind-main"}, + created_at="2026-05-27T01:00:00Z", + ), + types.SimpleNamespace( + id="rd-2", + caption="Decided project identity belongs in metadata.projectSlug.", + metadata={"projectSlug": "memind-main"}, + created_at="2026-05-26T01:00:00Z", + ), + ] + ) + + def query_items(self, user_id, agent_id, **kwargs): + self.item_calls.append((user_id, agent_id, kwargs)) + categories = kwargs.get("categories") or [] + items = { + "directive": [ + types.SimpleNamespace(id="it-1", text="Keep userId and agentId stable.", category="directive") + ], + "resolution": [ + types.SimpleNamespace(id="it-2", text="Clear retry events only after SUCCESS.", category="resolution") + ], + "playbook": [ + types.SimpleNamespace(id="it-3", text="Update all clients after Open API model changes.", category="playbook") + ], + "event": [ + types.SimpleNamespace(id="it-4", text="SessionStart currently performs health, replay, cleanup.", category="event") + ], + } + selected = [] + for category in categories: + selected.extend(items.get(category, [])) + return types.SimpleNamespace(items=selected) + + +class SessionContextTest(unittest.TestCase): + def test_build_session_context_queries_current_project_memory(self): + client = FakeClient() + + context = build_session_context( + client, + {"userId": "u", "agentId": "a"}, + "memind-main", + { + "sessionContextRecentSessions": 2, + "sessionContextMaxItems": 3, + "sessionContextMaxChars": 4000, + }, + ) + + rendered = render_session_context(context, {"sessionContextMaxChars": 4000}) + self.assertIn('', rendered) + self.assertIn("## Continue From", rendered) + self.assertIn("[rawdata:rd-1] Implemented generic memory query APIs", rendered) + self.assertIn("## Must Follow", rendered) + self.assertIn("[item:it-1 directive] Keep userId and agentId stable.", rendered) + self.assertIn("## Watch Outs", rendered) + self.assertIn("[item:it-2 resolution] Clear retry events only after SUCCESS.", rendered) + self.assertIn("## Reusable Playbooks", rendered) + self.assertIn("[item:it-3 playbook] Update all clients", rendered) + self.assertIn("## Useful Facts", rendered) + self.assertIn("[item:it-4 event] SessionStart currently performs health", rendered) + + raw_call = client.raw_data_calls[0][2] + self.assertEqual(raw_call["types"], ["agent_timeline"]) + self.assertEqual(raw_call["metadata_filter"]["all"][0]["path"], "projectSlug") + self.assertEqual(raw_call["metadata_filter"]["all"][0]["value"], "memind-main") + self.assertEqual(raw_call["limit"], 2) + self.assertEqual(raw_call["include"], {"metadata": True, "segment": False}) + self.assertTrue(all(call[2]["raw_data_types"] == ["agent_timeline"] for call in client.item_calls)) + + def test_render_session_context_respects_character_budget(self): + context = { + "projectSlug": "memind-main", + "recentRawData": [ + {"id": "rd-1", "caption": "A" * 120}, + {"id": "rd-2", "caption": "B" * 120}, + ], + "items": { + "directive": [{"id": "it-1", "category": "directive", "text": "C" * 120}], + "watchOut": [{"id": "it-2", "category": "resolution", "text": "D" * 120}], + "playbook": [{"id": "it-3", "category": "playbook", "text": "E" * 120}], + "fact": [{"id": "it-4", "category": "event", "text": "F" * 120}], + }, + } + + rendered = render_session_context(context, {"sessionContextMaxChars": 260}) + + self.assertLessEqual(len(rendered), 260) + self.assertIn("truncated", rendered) + self.assertTrue(rendered.endswith("")) + + +if __name__ == "__main__": + unittest.main() diff --git a/memind-integrations/codex/README.md b/memind-integrations/codex/README.md index 10962f39..e284b46a 100644 --- a/memind-integrations/codex/README.md +++ b/memind-integrations/codex/README.md @@ -120,7 +120,7 @@ The installed hooks are: | Codex event | Script | Timeout | Purpose | | --- | --- | ---: | --- | -| `SessionStart` | `scripts/session_start.py` | 5s | Replay at most one failed timeline payload and clean old state. | +| `SessionStart` | `scripts/session_start.py` | 5s | Replay at most one failed timeline payload, clean old state, and inject project continuity context when available. | | `UserPromptSubmit` | `scripts/retrieve.py` | 12s | Buffer the user prompt event and retrieve relevant Memind context. | | `PreToolUse` | `scripts/pre_tool_use.py` | 5s | Buffer a redacted tool-start event in local session state. | | `PostToolUse` | `scripts/post_tool_use.py` | 5s | Buffer a redacted tool-result event in local session state. | @@ -164,11 +164,15 @@ Settings are loaded in this order: | `agentId` | `coding-agent` | Shared Memind agent identity. Use the same value from Claude Code, Codex, and API clients to share one coding-agent memory space. | | `sourceClient` | `codex` | Source marker stored with Memind data. | | `autoRetrieve` | `true` | Enables prompt-time memory retrieval. | +| `autoSessionContext` | `true` | Enables SessionStart project continuity context injection. | | `autoIngestAgentTimeline` | `true` | Enables user prompt, tool/result, assistant message, and stop event buffering plus `agent_timeline` rawdata flush. | | `retrieveStrategy` | `SIMPLE` | Memind retrieval strategy. | | `retrieveMaxEntries` | `8` | Maximum formatted memory entries injected into Codex. | | `retrieveMaxChars` | `6000` | Maximum injected context characters. | | `retrieveContextTurns` | `0` | Number of recent transcript turns to include in the retrieval query. | +| `sessionContextRecentSessions` | `3` | Maximum recent `agent_timeline` captions shown at SessionStart. | +| `sessionContextMaxItems` | `6` | Maximum items fetched for each SessionStart context section. | +| `sessionContextMaxChars` | `6000` | Maximum SessionStart context characters. | | `ingestRetrySpool` | `true` | Enables file-backed retry for failed ingestion. | | `debug` | `false` | Writes debug logs to `~/.memind/codex.log`. | @@ -182,15 +186,18 @@ export MEMIND_API_TOKEN=... export MEMIND_USER_ID=local__alice export MEMIND_AGENT_ID=coding-agent export MEMIND_SOURCE_CLIENT=codex +export MEMIND_AUTO_SESSION_CONTEXT=true export MEMIND_AUTO_INGEST_AGENT_TIMELINE=true +export MEMIND_SESSION_CONTEXT_MAX_CHARS=6000 export MEMIND_RETRIEVE_CONTEXT_TURNS=0 export MEMIND_DEBUG=true ``` Additional environment variables include `MEMIND_AUTO_RETRIEVE`, `MEMIND_RETRIEVE_STRATEGY`, `MEMIND_RETRIEVE_MAX_ENTRIES`, `MEMIND_RETRIEVE_MAX_CHARS`, -`MEMIND_STATE_MAX_AGE_DAYS`, -`MEMIND_INGEST_RETRY_SPOOL`, `MEMIND_INGEST_RETRY_MAX_FILES`, and `MEMIND_INGEST_RETRY_MAX_AGE_DAYS`. +`MEMIND_SESSION_CONTEXT_RECENT_SESSIONS`, `MEMIND_SESSION_CONTEXT_MAX_ITEMS`, +`MEMIND_STATE_MAX_AGE_DAYS`, `MEMIND_INGEST_RETRY_SPOOL`, `MEMIND_INGEST_RETRY_MAX_FILES`, +and `MEMIND_INGEST_RETRY_MAX_AGE_DAYS`. ## Identity Model @@ -207,6 +214,37 @@ remote URL when available, otherwise the local project path. Project metadata su future context compilation without creating separate Memind core project or session entities. `sessionId`, `agentTurnId`, `timelineId`, and per-event turn metadata are also stored only inside raw content and item metadata. +## SessionStart Context + +When `autoSessionContext = true`, the `SessionStart` hook reads existing Memind data for the current `userId`, +`agentId`, and project `metadata.projectSlug`. It does not write rawdata and does not trigger memory extraction. + +The injected context is compiled from generic OpenAPI query results: + +```text + +Memind project memory. Use only when directly helpful. Prefer explicit user instructions and repository files over memory if they conflict. + +## Continue From +- [rawdata:rd-1] Previous turn summary from agent_timeline caption. + +## Must Follow +- [item:101 directive] Project or agent instruction extracted from previous work. + +## Watch Outs +- [item:102 resolution] Previously solved issue or failure pattern. + +## Reusable Playbooks +- [item:103 playbook] Repeatable workflow for this project. + +## Useful Facts +- [item:104 event] Project fact useful for continuing work. + +``` + +This project-continuity context is separate from prompt-time retrieval. It helps a new Codex session know what +recently happened in this project before the first user prompt is handled. + ## Retrieval Behavior Retrieval runs before each user prompt when `autoRetrieve = true`. diff --git a/memind-integrations/codex/install.sh b/memind-integrations/codex/install.sh index 8e91ba38..0f767e10 100644 --- a/memind-integrations/codex/install.sh +++ b/memind-integrations/codex/install.sh @@ -99,6 +99,9 @@ required = [ "MemindClient", "ConversationContent", "Message", + "QueryMemoryItemsRequest", + "QueryMemoryRawDataRequest", + "RawDataQueryIncludeOptions", "Strategy", ] missing = [name for name in required if not hasattr(memind, name)] @@ -189,6 +192,7 @@ download_remote_install() { "scripts/lib/identity.py" "scripts/lib/logging_utils.py" "scripts/lib/retry.py" + "scripts/lib/session_context.py" "scripts/lib/state.py" ) mkdir -p "${INSTALL_ROOT}" diff --git a/memind-integrations/codex/scripts/lib/client.py b/memind-integrations/codex/scripts/lib/client.py index efcdf4f7..ac559888 100644 --- a/memind-integrations/codex/scripts/lib/client.py +++ b/memind-integrations/codex/scripts/lib/client.py @@ -75,3 +75,78 @@ def retrieve(self, user_id, agent_id, query, strategy="SIMPLE", trace=False): strategy=strategy, trace=trace, ) + + def query_items( + self, + user_id, + agent_id, + scope=None, + categories=None, + source_clients=None, + raw_data_types=None, + time_range=None, + metadata_filter=None, + limit=None, + cursor=None, + ): + from memind import MemindClient as OfficialMemindClient + from memind import QueryMemoryItemsRequest + + request = QueryMemoryItemsRequest( + user_id=user_id, + agent_id=agent_id, + scope=scope, + categories=categories, + source_clients=source_clients, + raw_data_types=raw_data_types, + time_range=time_range, + metadata_filter=metadata_filter, + limit=limit, + cursor=cursor, + ) + with OfficialMemindClient( + base_url=self.base_url, + api_token=self.token, + timeout=self.timeout, + max_retries=self.max_retries, + ) as client: + return client.memory.query_items(request) + + def query_raw_data( + self, + user_id, + agent_id, + types=None, + source_clients=None, + time_range=None, + metadata_filter=None, + include=None, + limit=None, + cursor=None, + ): + from memind import MemindClient as OfficialMemindClient + from memind import QueryMemoryRawDataRequest, RawDataQueryIncludeOptions + + include_options = ( + RawDataQueryIncludeOptions(**include) + if isinstance(include, dict) + else include + ) + request = QueryMemoryRawDataRequest( + user_id=user_id, + agent_id=agent_id, + types=types, + source_clients=source_clients, + time_range=time_range, + metadata_filter=metadata_filter, + include=include_options, + limit=limit, + cursor=cursor, + ) + with OfficialMemindClient( + base_url=self.base_url, + api_token=self.token, + timeout=self.timeout, + max_retries=self.max_retries, + ) as client: + return client.memory.query_raw_data(request) diff --git a/memind-integrations/codex/scripts/lib/config.py b/memind-integrations/codex/scripts/lib/config.py index 14228b0f..eb61347d 100644 --- a/memind-integrations/codex/scripts/lib/config.py +++ b/memind-integrations/codex/scripts/lib/config.py @@ -23,12 +23,16 @@ "agentId": "coding-agent", "sourceClient": "codex", "autoRetrieve": True, + "autoSessionContext": True, "autoIngestAgentTimeline": True, "retrieveStrategy": "SIMPLE", "retrieveMaxEntries": 8, "retrieveMaxChars": 6000, "retrievePromptPreamble": "Relevant memories from Memind. Use only when directly helpful:", "retrieveContextTurns": 0, + "sessionContextRecentSessions": 3, + "sessionContextMaxItems": 6, + "sessionContextMaxChars": 6000, "stateMaxAgeDays": 14, "ingestRetrySpool": True, "ingestRetryMaxFiles": 20, @@ -43,11 +47,15 @@ "MEMIND_AGENT_ID": ("agentId", str), "MEMIND_SOURCE_CLIENT": ("sourceClient", str), "MEMIND_AUTO_RETRIEVE": ("autoRetrieve", "bool"), + "MEMIND_AUTO_SESSION_CONTEXT": ("autoSessionContext", "bool"), "MEMIND_AUTO_INGEST_AGENT_TIMELINE": ("autoIngestAgentTimeline", "bool"), "MEMIND_RETRIEVE_STRATEGY": ("retrieveStrategy", str), "MEMIND_RETRIEVE_MAX_ENTRIES": ("retrieveMaxEntries", "int"), "MEMIND_RETRIEVE_MAX_CHARS": ("retrieveMaxChars", "int"), "MEMIND_RETRIEVE_CONTEXT_TURNS": ("retrieveContextTurns", "int_allow_zero"), + "MEMIND_SESSION_CONTEXT_RECENT_SESSIONS": ("sessionContextRecentSessions", "int"), + "MEMIND_SESSION_CONTEXT_MAX_ITEMS": ("sessionContextMaxItems", "int"), + "MEMIND_SESSION_CONTEXT_MAX_CHARS": ("sessionContextMaxChars", "int"), "MEMIND_STATE_MAX_AGE_DAYS": ("stateMaxAgeDays", "int"), "MEMIND_INGEST_RETRY_SPOOL": ("ingestRetrySpool", "bool"), "MEMIND_INGEST_RETRY_MAX_FILES": ("ingestRetryMaxFiles", "int"), diff --git a/memind-integrations/codex/scripts/lib/session_context.py b/memind-integrations/codex/scripts/lib/session_context.py new file mode 100644 index 00000000..ff8298b3 --- /dev/null +++ b/memind-integrations/codex/scripts/lib/session_context.py @@ -0,0 +1,195 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import html + +DEFAULT_RECENT_SESSIONS = 3 +DEFAULT_MAX_ITEMS = 6 +DEFAULT_MAX_CHARS = 6000 + +SECTION_ORDER = [ + ("recentRawData", "## Continue From"), + ("directive", "## Must Follow"), + ("watchOut", "## Watch Outs"), + ("playbook", "## Reusable Playbooks"), + ("fact", "## Useful Facts"), +] + + +def project_metadata_filter(project_slug): + return {"all": [{"path": "projectSlug", "op": "eq", "value": project_slug}]} + + +def build_session_context(client, identity, project_slug, config): + metadata_filter = project_metadata_filter(project_slug) + recent_limit = int(config.get("sessionContextRecentSessions", DEFAULT_RECENT_SESSIONS)) + max_items = int(config.get("sessionContextMaxItems", DEFAULT_MAX_ITEMS)) + + raw_data = client.query_raw_data( + identity["userId"], + identity["agentId"], + types=["agent_timeline"], + metadata_filter=metadata_filter, + include={"metadata": True, "segment": False}, + limit=recent_limit, + ) + recent_raw_data = [_raw_data_entry(raw) for raw in getattr(raw_data, "raw_data", [])] + recent_raw_data = [entry for entry in recent_raw_data if entry.get("caption")] + + directives = _query_items( + client, + identity, + ["directive"], + metadata_filter, + max_items, + ) + watch_outs = _query_items( + client, + identity, + ["resolution"], + metadata_filter, + max_items, + ) + playbooks = _query_items( + client, + identity, + ["playbook"], + metadata_filter, + max_items, + ) + facts = _query_items( + client, + identity, + ["event", "profile", "behavior", "tool"], + metadata_filter, + max_items, + ) + + return { + "projectSlug": project_slug, + "recentRawData": recent_raw_data, + "items": { + "directive": directives, + "watchOut": watch_outs, + "playbook": playbooks, + "fact": facts, + }, + } + + +def render_session_context(context, config): + project_slug = context.get("projectSlug") or "unknown" + max_chars = int(config.get("sessionContextMaxChars", DEFAULT_MAX_CHARS)) + header = f'' + preamble = ( + "Memind project memory. Use only when directly helpful. Prefer explicit " + "user instructions and repository files over memory if they conflict." + ) + footer = "" + lines = [header, preamble] + + for key, title in SECTION_ORDER: + entries = _section_entries(context, key) + if not entries: + continue + lines.append("") + lines.append(title) + for entry in entries: + lines.append(_render_entry(key, entry)) + + if len(lines) <= 2: + return "" + + full = "\n".join(lines + [footer]) + if len(full) <= max_chars: + return full + + return _truncate_lines(lines, footer, max_chars) + + +def _query_items(client, identity, categories, metadata_filter, limit): + response = client.query_items( + identity["userId"], + identity["agentId"], + categories=categories, + raw_data_types=["agent_timeline"], + metadata_filter=metadata_filter, + limit=limit, + ) + return [_item_entry(item) for item in getattr(response, "items", []) if _text(item)] + + +def _raw_data_entry(raw): + return { + "id": _field(raw, "id"), + "caption": _field(raw, "caption"), + "createdAt": _field(raw, "created_at"), + "metadata": _field(raw, "metadata") or {}, + } + + +def _item_entry(item): + return { + "id": _field(item, "id"), + "text": _text(item), + "category": (_field(item, "category") or "").lower(), + "createdAt": _field(item, "created_at"), + "metadata": _field(item, "metadata") or {}, + } + + +def _section_entries(context, key): + if key == "recentRawData": + return context.get("recentRawData") or [] + return (context.get("items") or {}).get(key) or [] + + +def _render_entry(section_key, entry): + if section_key == "recentRawData": + return f"- [rawdata:{entry.get('id')}] {_clean(entry.get('caption'))}" + category = entry.get("category") or "memory" + return f"- [item:{entry.get('id')} {category}] {_clean(entry.get('text'))}" + + +def _truncate_lines(lines, footer, max_chars): + notice = "\n[truncated]\n" + budget = max(0, max_chars - len(notice) - len(footer)) + selected = [] + used = 0 + for line in lines: + addition = len(line) + (1 if selected else 0) + if used + addition > budget: + break + selected.append(line) + used += addition + if len(selected) <= 2: + base = "\n".join(lines[:2]) + available = max(0, budget - len(base)) + base = base[:available] + return f"{base}{notice}{footer}"[:max_chars] + return f"{chr(10).join(selected)}{notice}{footer}"[:max_chars] + + +def _field(value, name): + if isinstance(value, dict): + return value.get(name) + return getattr(value, name, None) + + +def _text(item): + return _field(item, "text") + + +def _clean(value): + return " ".join(str(value or "").split()) diff --git a/memind-integrations/codex/scripts/session_start.py b/memind-integrations/codex/scripts/session_start.py index a4e21d65..9db8da28 100644 --- a/memind-integrations/codex/scripts/session_start.py +++ b/memind-integrations/codex/scripts/session_start.py @@ -26,8 +26,10 @@ from ingest import retry_root, state_root from lib.client import MemindClient from lib.config import load_config +from lib.identity import project_slug, resolve_identity from lib.logging_utils import debug_log from lib.retry import RetrySpool +from lib.session_context import build_session_context, render_session_context from lib.state import SessionStateStore @@ -65,7 +67,31 @@ async def _replay_payload(client, payload): return 0 -async def run_session_start_async(config): +def _base_output(): + return {"continue": True, "suppressOutput": True} + + +def _context_output(client, config, hook_input): + if not config.get("autoSessionContext", True): + return None + cwd = hook_input.get("cwd") or os.getcwd() + identity = resolve_identity(config, hook_input) + slug = project_slug(cwd) + context = build_session_context(client, identity, slug, config) + rendered = render_session_context(context, config) + if not rendered: + return None + return { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": rendered, + } + } + + +async def run_session_start_async(config, hook_input=None): + hook_input = hook_input or {} + output = _base_output() try: client = MemindClient(config["memindApiUrl"], config.get("memindApiToken"), timeout=2, max_retries=0) try: @@ -74,7 +100,7 @@ async def run_session_start_async(config): _tcp_check(config["memindApiUrl"], timeout=1) except Exception as exc: debug_log(config, "session_start_health_failed", {"error": str(exc)}) - return + return output spool = RetrySpool(retry_root()) claimed = None @@ -98,22 +124,34 @@ async def run_session_start_async(config): SessionStateStore(state_root()).cleanup(int(config.get("stateMaxAgeDays", 14))) except Exception as exc: debug_log(config, "state_cleanup_failed", {"error": str(exc)}) + try: + context_output = _context_output(client, config, hook_input) + if context_output: + output.update(context_output) + except Exception as exc: + debug_log(config, "session_context_failed", {"error": str(exc)}) + return output -def run_session_start(config): - asyncio.run(run_session_start_async(config)) +def run_session_start(config, hook_input=None): + return asyncio.run(run_session_start_async(config, hook_input)) def main(): try: config = load_config() - run_session_start(config) + try: + hook_input = json.loads(sys.stdin.read() or "{}") + except Exception: + hook_input = {} + output = run_session_start(config, hook_input) except Exception as exc: try: debug_log(load_config(), "session_start_failed", {"error": str(exc)}) except Exception: pass - print(json.dumps({"continue": True, "suppressOutput": True})) + output = _base_output() + print(json.dumps(output)) if __name__ == "__main__": diff --git a/memind-integrations/codex/settings.json b/memind-integrations/codex/settings.json index 09fba74d..68c36ce7 100644 --- a/memind-integrations/codex/settings.json +++ b/memind-integrations/codex/settings.json @@ -5,12 +5,16 @@ "agentId": "coding-agent", "sourceClient": "codex", "autoRetrieve": true, + "autoSessionContext": true, "autoIngestAgentTimeline": true, "retrieveStrategy": "SIMPLE", "retrieveMaxEntries": 8, "retrieveMaxChars": 6000, "retrievePromptPreamble": "Relevant memories from Memind. Use only when directly helpful:", "retrieveContextTurns": 0, + "sessionContextRecentSessions": 3, + "sessionContextMaxItems": 6, + "sessionContextMaxChars": 6000, "stateMaxAgeDays": 14, "ingestRetrySpool": true, "ingestRetryMaxFiles": 20, diff --git a/memind-integrations/codex/tests/test_client.py b/memind-integrations/codex/tests/test_client.py index 1a7f1042..6af43da9 100644 --- a/memind-integrations/codex/tests/test_client.py +++ b/memind-integrations/codex/tests/test_client.py @@ -73,6 +73,14 @@ def retrieve(self, **kwargs): } ) + def query_items(self, request): + self.calls.append(("query_items", request)) + return SimpleNamespace(items=[SimpleNamespace(id="it-1", text="Use cargo test")]) + + def query_raw_data(self, request): + self.calls.append(("query_raw_data", request)) + return SimpleNamespace(raw_data=[SimpleNamespace(id="rd-1", caption="Fixed Codex hook")]) + class _FakeSyncMemindClient: instances = [] @@ -101,11 +109,45 @@ def __new__(cls, value): return str.__new__(cls, value) +class _MetadataCondition: + def __init__(self, **kwargs): + self.path = kwargs.get("path") + self.op = kwargs.get("op") + self.value = kwargs.get("value") + + +class _MetadataFilter: + def __init__(self, all=None, **kwargs): + self.all = [_MetadataCondition(**item) for item in (all or [])] + + +class _RawDataQueryIncludeOptions: + def __init__(self, segment=None, metadata=None): + self.segment = segment + self.metadata = metadata + + +class _QueryMemoryItemsRequest: + def __init__(self, metadata_filter=None, **kwargs): + self.__dict__.update(kwargs) + self.metadata_filter = _MetadataFilter(**metadata_filter) if isinstance(metadata_filter, dict) else metadata_filter + + +class _QueryMemoryRawDataRequest: + def __init__(self, metadata_filter=None, include=None, **kwargs): + self.__dict__.update(kwargs) + self.metadata_filter = _MetadataFilter(**metadata_filter) if isinstance(metadata_filter, dict) else metadata_filter + self.include = include + + def _fake_memind_module(): module = types.ModuleType("memind") module.AsyncMemindClient = _FakeAsyncMemindClient module.MemindClient = _FakeSyncMemindClient module.Strategy = _Strategy + module.QueryMemoryItemsRequest = _QueryMemoryItemsRequest + module.QueryMemoryRawDataRequest = _QueryMemoryRawDataRequest + module.RawDataQueryIncludeOptions = _RawDataQueryIncludeOptions return module @@ -155,6 +197,37 @@ def test_retrieve_uses_official_sync_client(self): self.assertEqual(result.model_dump(by_alias=True)["items"][0]["text"], "remember espresso") self.assertTrue(_FakeSyncMemindClient.instances[0].closed) + def test_query_wrappers_use_official_sync_query_models(self): + with mock.patch.dict(sys.modules, {"memind": _fake_memind_module()}): + MemindClient = _load_client_class() + client = MemindClient("http://127.0.0.1:8366", timeout=12, max_retries=0) + items = client.query_items( + "u", + "a", + categories=["playbook"], + raw_data_types=["agent_timeline"], + metadata_filter={"all": [{"path": "projectSlug", "op": "eq", "value": "memind"}]}, + limit=5, + ) + raw_data = client.query_raw_data( + "u", + "a", + types=["agent_timeline"], + metadata_filter={"all": [{"path": "projectSlug", "op": "eq", "value": "memind"}]}, + include={"metadata": True, "segment": False}, + limit=3, + ) + + self.assertEqual(items.items[0].id, "it-1") + self.assertEqual(raw_data.raw_data[0].id, "rd-1") + item_request = _FakeSyncMemindClient.instances[0].memory.calls[0][1] + raw_data_request = _FakeSyncMemindClient.instances[1].memory.calls[0][1] + self.assertEqual(item_request.user_id, "u") + self.assertEqual(item_request.categories, ["playbook"]) + self.assertEqual(item_request.metadata_filter.all[0].value, "memind") + self.assertEqual(raw_data_request.types, ["agent_timeline"]) + self.assertFalse(raw_data_request.include.segment) + async def test_missing_official_client_import_propagates_to_hook_fail_open_boundary(self): with mock.patch.dict(sys.modules, {"memind": None}): MemindClient = _load_client_class() diff --git a/memind-integrations/codex/tests/test_config.py b/memind-integrations/codex/tests/test_config.py index 93ae882d..1ee75813 100644 --- a/memind-integrations/codex/tests/test_config.py +++ b/memind-integrations/codex/tests/test_config.py @@ -26,6 +26,10 @@ def test_defaults_are_codex_specific(self): self.assertEqual(config["agentId"], "coding-agent") self.assertEqual(config["sourceClient"], "codex") self.assertEqual(config["retrieveContextTurns"], 0) + self.assertTrue(config["autoSessionContext"]) + self.assertEqual(config["sessionContextRecentSessions"], 3) + self.assertEqual(config["sessionContextMaxItems"], 6) + self.assertEqual(config["sessionContextMaxChars"], 6000) self.assertNotIn("agentIdMode", config) self.assertNotIn("commitOnStop", config) @@ -36,6 +40,10 @@ def test_user_config_and_env_override_settings(self): env = { "MEMIND_API_URL": "http://example.test", "MEMIND_RETRIEVE_CONTEXT_TURNS": "2", + "MEMIND_AUTO_SESSION_CONTEXT": "false", + "MEMIND_SESSION_CONTEXT_RECENT_SESSIONS": "4", + "MEMIND_SESSION_CONTEXT_MAX_ITEMS": "5", + "MEMIND_SESSION_CONTEXT_MAX_CHARS": "3000", } config = load_config( plugin_root=Path(__file__).resolve().parents[1], @@ -45,6 +53,10 @@ def test_user_config_and_env_override_settings(self): self.assertEqual(config["agentId"], "custom") self.assertEqual(config["memindApiUrl"], "http://example.test") self.assertEqual(config["retrieveContextTurns"], 2) + self.assertFalse(config["autoSessionContext"]) + self.assertEqual(config["sessionContextRecentSessions"], 4) + self.assertEqual(config["sessionContextMaxItems"], 5) + self.assertEqual(config["sessionContextMaxChars"], 3000) self.assertNotIn("agentIdMode", config) self.assertNotIn("commitOnStop", config) self.assertNotIn("ingestionRoles", config) diff --git a/memind-integrations/codex/tests/test_hooks.py b/memind-integrations/codex/tests/test_hooks.py index 97a747d0..d743544e 100644 --- a/memind-integrations/codex/tests/test_hooks.py +++ b/memind-integrations/codex/tests/test_hooks.py @@ -512,6 +512,72 @@ def test_session_start_replays_agent_timeline_payload_and_clears_event_ids(self) self.assertEqual(list(retry_root.glob("*.json")), []) client.extract.assert_awaited_once() + def test_session_start_injects_project_context_when_available(self): + sys.path.insert(0, str(ROOT / "scripts")) + import session_start + + config = { + "memindApiUrl": "http://127.0.0.1:8366", + "memindApiToken": None, + "sourceClient": "codex", + "userId": "u", + "agentId": "a", + "autoSessionContext": True, + "sessionContextRecentSessions": 1, + "sessionContextMaxItems": 3, + "sessionContextMaxChars": 4000, + "ingestRetryMaxFiles": 20, + "ingestRetryMaxAgeDays": 7, + "stateMaxAgeDays": 14, + "debug": False, + } + + with tempfile.TemporaryDirectory() as tmp: + retry_root = Path(tmp) / "retry" + state_root = Path(tmp) / "state" + with mock.patch.object(session_start, "retry_root", return_value=retry_root): + with mock.patch.object(session_start, "state_root", return_value=state_root): + with mock.patch.object(session_start, "project_slug", return_value="memind-main"): + with mock.patch.object(session_start, "MemindClient") as client_cls: + client = client_cls.return_value + client.health = mock.AsyncMock(return_value=types.SimpleNamespace(status="UP")) + client.query_raw_data.return_value = types.SimpleNamespace( + raw_data=[ + types.SimpleNamespace( + id="rd-1", + caption="Implemented Codex SessionStart context.", + metadata={}, + ) + ] + ) + client.query_items.side_effect = [ + types.SimpleNamespace( + items=[ + types.SimpleNamespace( + id="it-1", + category="directive", + text="Keep Codex and Claude Code aligned.", + ) + ] + ), + types.SimpleNamespace(items=[]), + types.SimpleNamespace(items=[]), + types.SimpleNamespace(items=[]), + ] + output = session_start.run_session_start( + config, + {"cwd": tmp, "session_id": "s1"}, + ) + + self.assertIn("hookSpecificOutput", output) + hook_output = output["hookSpecificOutput"] + self.assertEqual(hook_output["hookEventName"], "SessionStart") + self.assertIn("## Continue From", hook_output["additionalContext"]) + self.assertIn("[rawdata:rd-1] Implemented Codex SessionStart context.", hook_output["additionalContext"]) + self.assertIn("[item:it-1 directive] Keep Codex and Claude Code aligned.", hook_output["additionalContext"]) + query_raw_call = client.query_raw_data.call_args.kwargs + self.assertEqual(query_raw_call["metadata_filter"]["all"][0]["value"], "memind-main") + if __name__ == "__main__": unittest.main() diff --git a/memind-integrations/codex/tests/test_installer.py b/memind-integrations/codex/tests/test_installer.py index b2e6c8be..42a3bc7e 100644 --- a/memind-integrations/codex/tests/test_installer.py +++ b/memind-integrations/codex/tests/test_installer.py @@ -69,6 +69,9 @@ def _valid_memind_package(root): "MemindClient", "ConversationContent", "Message", + "QueryMemoryItemsRequest", + "QueryMemoryRawDataRequest", + "RawDataQueryIncludeOptions", "Strategy", ], ) diff --git a/memind-integrations/codex/tests/test_session_context.py b/memind-integrations/codex/tests/test_session_context.py new file mode 100644 index 00000000..74015d4e --- /dev/null +++ b/memind-integrations/codex/tests/test_session_context.py @@ -0,0 +1,96 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import sys +import types +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +from scripts.lib.session_context import build_session_context, render_session_context + + +class FakeClient: + def __init__(self): + self.raw_data_calls = [] + self.item_calls = [] + + def query_raw_data(self, user_id, agent_id, **kwargs): + self.raw_data_calls.append((user_id, agent_id, kwargs)) + return types.SimpleNamespace( + raw_data=[ + types.SimpleNamespace(id="rd-1", caption="Fixed Codex agent timeline flushing.", metadata={}), + ] + ) + + def query_items(self, user_id, agent_id, **kwargs): + self.item_calls.append((user_id, agent_id, kwargs)) + categories = kwargs.get("categories") or [] + items = { + "directive": [ + types.SimpleNamespace(id="it-1", text="Keep Codex and Claude Code behavior aligned.", category="directive") + ], + "resolution": [ + types.SimpleNamespace(id="it-2", text="Use sessionKey when clearing Codex retry events.", category="resolution") + ], + "playbook": [ + types.SimpleNamespace(id="it-3", text="Run Codex integration tests after hook changes.", category="playbook") + ], + "event": [ + types.SimpleNamespace(id="it-4", text="Codex SessionStart replays agent_timeline retry payloads.", category="event") + ], + } + selected = [] + for category in categories: + selected.extend(items.get(category, [])) + return types.SimpleNamespace(items=selected) + + +class SessionContextTest(unittest.TestCase): + def test_build_session_context_renders_codex_project_context(self): + client = FakeClient() + + context = build_session_context( + client, + {"userId": "u", "agentId": "a"}, + "memind-main", + { + "sessionContextRecentSessions": 2, + "sessionContextMaxItems": 3, + "sessionContextMaxChars": 4000, + }, + ) + + rendered = render_session_context(context, {"sessionContextMaxChars": 4000}) + self.assertIn('', rendered) + self.assertIn("## Continue From", rendered) + self.assertIn("[rawdata:rd-1] Fixed Codex agent timeline flushing.", rendered) + self.assertIn("## Must Follow", rendered) + self.assertIn("[item:it-1 directive] Keep Codex and Claude Code behavior aligned.", rendered) + self.assertIn("## Watch Outs", rendered) + self.assertIn("[item:it-2 resolution] Use sessionKey", rendered) + self.assertIn("## Reusable Playbooks", rendered) + self.assertIn("[item:it-3 playbook] Run Codex integration tests", rendered) + self.assertIn("## Useful Facts", rendered) + self.assertIn("[item:it-4 event] Codex SessionStart replays", rendered) + + raw_call = client.raw_data_calls[0][2] + self.assertEqual(raw_call["metadata_filter"]["all"][0]["value"], "memind-main") + self.assertTrue(all(call[2]["raw_data_types"] == ["agent_timeline"] for call in client.item_calls)) + + +if __name__ == "__main__": + unittest.main() From 4468583bdca7c1f0ea326b02508860b3cd769c48 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 10:40:19 +0800 Subject: [PATCH 30/54] feat: add claude code agent context compiler --- .../scripts/lib/context_compiler.py | 499 ++++++++++++++++++ .../tests/test_context_compiler.py | 140 +++++ 2 files changed, 639 insertions(+) create mode 100644 memind-integrations/claude-code/scripts/lib/context_compiler.py create mode 100644 memind-integrations/claude-code/tests/test_context_compiler.py diff --git a/memind-integrations/claude-code/scripts/lib/context_compiler.py b/memind-integrations/claude-code/scripts/lib/context_compiler.py new file mode 100644 index 00000000..a3b45611 --- /dev/null +++ b/memind-integrations/claude-code/scripts/lib/context_compiler.py @@ -0,0 +1,499 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import html +import re +import string +from datetime import datetime + +DEFAULT_MAX_CHARS = 6000 +DEFAULT_SESSION_ENTRY_MAX_CHARS = 520 +DEFAULT_RETRIEVAL_ENTRY_MAX_CHARS = 700 + +SESSION_SECTION_ORDER = [ + ("continueFrom", "## Continue From"), + ("mustFollow", "## Must Follow"), + ("watchOuts", "## Watch Outs"), + ("playbooks", "## Reusable Playbooks"), + ("facts", "## Useful Facts"), +] + +SESSION_SECTION_BUDGETS = { + "continueFrom": 1300, + "mustFollow": 1300, + "watchOuts": 1400, + "playbooks": 1200, + "facts": 800, +} + +PROMPT_SECTION_ORDER = [ + ("directives", "## Directives"), + ("resolvedProblems", "## Resolved Problems"), + ("playbooks", "## Agent Playbooks"), + ("toolNotes", "## Tool Notes"), + ("insights", "## Insights"), + ("memoryItems", "## Memory Items"), +] + +PROMPT_SECTION_BUDGETS = { + "directives": 900, + "resolvedProblems": 1600, + "playbooks": 1200, + "toolNotes": 900, + "insights": 800, + "memoryItems": 600, +} + +PROMPT_SECTION_LIMITS = { + "directives": 3, + "resolvedProblems": 5, + "playbooks": 4, + "toolNotes": 3, + "insights": 3, + "memoryItems": 3, +} + +SECTION_FIT_PRIORITY = { + "memind_session_context": ["mustFollow", "watchOuts", "continueFrom", "playbooks", "facts"], + "memind_memories": ["directives", "resolvedProblems", "playbooks", "toolNotes", "insights", "memoryItems"], +} + +WATCH_OUT_TERMS = { + "error", + "failed", + "failure", + "fix", + "fixed", + "regression", + "test", + "timeout", + "retry", + "avoid", +} + +PLAYBOOK_TERMS = {"run", "after", "before", "when", "then", "workflow", "steps", "verify"} + + +def compile_session_start_context(context, config): + sections = { + "continueFrom": _normalize_rawdata(context.get("recentRawData") or []), + "mustFollow": _normalize_items(((context.get("items") or {}).get("directive") or []), "mustFollow"), + "watchOuts": _normalize_items(((context.get("items") or {}).get("watchOut") or []), "watchOuts"), + "playbooks": _normalize_items(((context.get("items") or {}).get("playbook") or []), "playbooks"), + "facts": _normalize_items(((context.get("items") or {}).get("fact") or []), "facts"), + } + project_slug = context.get("projectSlug") or "unknown" + max_chars = int(config.get("sessionContextMaxChars", DEFAULT_MAX_CHARS)) + entry_max_chars = int(config.get("sessionContextEntryMaxChars", DEFAULT_SESSION_ENTRY_MAX_CHARS)) + return _render_context( + wrapper="memind_session_context", + attrs={"project": project_slug}, + preamble=( + "Historical Memind project memory. Use only when directly helpful. " + "Current user instructions and repository files take precedence. " + "Verify old implementation details against the working tree before relying on them." + ), + sections=_prepare_sections(sections, "session_start"), + order=SESSION_SECTION_ORDER, + budgets=SESSION_SECTION_BUDGETS, + max_chars=max_chars, + entry_max_chars=entry_max_chars, + ) + + +def compile_prompt_retrieval_context(data, config): + max_entries = int(config.get("retrieveMaxEntries", 8)) + max_chars = int(config.get("retrieveMaxChars", DEFAULT_MAX_CHARS)) + entry_max_chars = int(config.get("retrieveEntryMaxChars", DEFAULT_RETRIEVAL_ENTRY_MAX_CHARS)) + + items = [_normalize_retrieved_item(item) for item in data.get("items") or [] if _field(item, "text")] + insights = [_normalize_insight(insight) for insight in data.get("insights") or [] if _field(insight, "text")] + sorted_items = _sort_prompt_items(items) + + sections = { + "directives": _top_category(sorted_items, "directive", _section_limit("directives", max_entries)), + "resolvedProblems": _top_category(sorted_items, "resolution", _section_limit("resolvedProblems", max_entries)), + "playbooks": _top_category(sorted_items, "playbook", _section_limit("playbooks", max_entries)), + "toolNotes": _top_category(sorted_items, "tool", _section_limit("toolNotes", max_entries)), + "insights": _sort_insights(insights)[: _section_limit("insights", max_entries)], + "memoryItems": _top_general_items(sorted_items, _section_limit("memoryItems", max_entries)), + } + + degraded_notice = "" + if data.get("status") == "degraded": + degraded_notice = "[Note: Memory retrieval encountered an error. Results may be incomplete.]" + + preamble = config.get("retrievePromptPreamble") or ( + "Relevant Memind memories for the current request. Use only when directly helpful." + ) + rendered = _render_context( + wrapper="memind_memories", + attrs={}, + preamble=preamble, + sections=_prepare_sections(sections, "prompt_retrieval"), + order=PROMPT_SECTION_ORDER, + budgets=PROMPT_SECTION_BUDGETS, + max_chars=max_chars, + entry_max_chars=entry_max_chars, + trailing_notice=degraded_notice, + ) + if rendered or not degraded_notice: + return rendered + return _render_context( + wrapper="memind_memories", + attrs={}, + preamble=preamble, + sections={}, + order=PROMPT_SECTION_ORDER, + budgets=PROMPT_SECTION_BUDGETS, + max_chars=max_chars, + entry_max_chars=entry_max_chars, + trailing_notice=degraded_notice, + allow_notice_only=True, + ) + + +def _prepare_sections(sections, mode): + prepared = {} + high_value_seen = set() + for key, entries in sections.items(): + ranked = _rank_entries(entries, key, mode) + deduped = [] + section_seen = set() + for entry in ranked: + dedupe_key = _dedupe_key(entry["text"]) + if not dedupe_key or dedupe_key in section_seen: + continue + if key in {"facts", "memoryItems"} and dedupe_key in high_value_seen: + continue + section_seen.add(dedupe_key) + deduped.append(entry) + if key not in {"continueFrom", "facts", "memoryItems"}: + high_value_seen.update(_dedupe_key(entry["text"]) for entry in deduped if _dedupe_key(entry["text"])) + if deduped: + prepared[key] = deduped + return prepared + + +def _normalize_rawdata(raw_data): + entries = [] + for raw in raw_data: + text = _field(raw, "caption") + if not text: + continue + entries.append( + { + "kind": "rawdata", + "id": _field(raw, "id"), + "category": "agent_timeline", + "text": _clean(text), + "createdAt": _field(raw, "createdAt") or _field(raw, "created_at"), + "score": 0, + } + ) + return entries + + +def _normalize_items(items, section): + entries = [] + for item in items: + text = _field(item, "text") + if not text: + continue + category = str(_field(item, "category") or "memory").strip().lower() + entries.append( + { + "kind": "item", + "id": _field(item, "id"), + "category": category, + "text": _clean(text), + "createdAt": _field(item, "createdAt") or _field(item, "created_at"), + "score": _section_score(section, text), + } + ) + return entries + + +def _normalize_retrieved_item(item): + category = str(_field(item, "category") or "memory").strip().lower() + return { + "kind": "item", + "id": _field(item, "id"), + "category": category, + "text": _clean(_field(item, "text")), + "createdAt": _field(item, "createdAt") or _field(item, "created_at"), + "score": _number(_field(item, "finalScore"), _field(item, "vectorScore"), 0), + } + + +def _normalize_insight(insight): + return { + "kind": "insight", + "id": _field(insight, "id"), + "category": str(_field(insight, "tier") or "insight").strip().lower(), + "text": _clean(_field(insight, "text")), + "createdAt": _field(insight, "createdAt") or _field(insight, "created_at"), + "score": 0, + } + + +def _rank_entries(entries, section, mode): + if section == "continueFrom": + return sorted(entries, key=lambda entry: _timestamp(entry.get("createdAt")), reverse=True)[:3] + return sorted( + entries, + key=lambda entry: ( + entry.get("score", 0), + _timestamp(entry.get("createdAt")), + -len(entry.get("text", "")), + ), + reverse=True, + ) + + +def _sort_prompt_items(items): + return sorted( + items, + key=lambda entry: ( + entry.get("score", 0), + _timestamp(entry.get("createdAt")), + _category_priority(entry.get("category")), + ), + reverse=True, + ) + + +def _sort_insights(insights): + tier_rank = {"root": 3, "branch": 2, "leaf": 1} + return sorted( + insights, + key=lambda entry: ( + tier_rank.get(entry.get("category", ""), 0), + _timestamp(entry.get("createdAt")), + str(entry.get("id") or ""), + ), + reverse=True, + ) + + +def _top_category(items, category, limit): + return [entry for entry in items if entry["category"] == category][:limit] + + +def _top_general_items(items, limit): + agent_categories = {"directive", "resolution", "playbook", "tool"} + return [entry for entry in items if entry["category"] not in agent_categories][:limit] + + +def _section_limit(section, max_entries): + return max(0, min(PROMPT_SECTION_LIMITS.get(section, max_entries), max_entries)) + + +def _category_priority(category): + return {"directive": 5, "resolution": 4, "playbook": 3, "tool": 2}.get(category or "", 1) + + +def _section_score(section, text): + lowered = str(text or "").lower() + if section == "watchOuts": + return sum(1 for term in WATCH_OUT_TERMS if term in lowered) + if section == "playbooks": + return sum(1 for term in PLAYBOOK_TERMS if term in lowered) + if section == "mustFollow": + return 2 if len(lowered) <= 220 else 1 + return 0 + + +def _render_context( + wrapper, + attrs, + preamble, + sections, + order, + budgets, + max_chars, + entry_max_chars, + trailing_notice="", + allow_notice_only=False, +): + if not sections and not trailing_notice and not allow_notice_only: + return "" + + open_tag = _open_tag(wrapper, attrs) + close_tag = f"" + fixed_lines = [open_tag, preamble] + rendered_sections = [] + truncated = False + + for key, title in order: + entries = sections.get(key) or [] + if not entries: + continue + budget = budgets.get(key, 800) + lines, section_truncated = _render_section(title, entries, budget, entry_max_chars) + truncated = truncated or section_truncated + if lines: + rendered_sections.append((key, lines)) + + if trailing_notice: + rendered_sections.append(("notice", [trailing_notice])) + + if not rendered_sections and not allow_notice_only: + return "" + + lines = list(fixed_lines) + for _key, section_lines in rendered_sections: + lines.append("") + lines.extend(section_lines) + if truncated: + lines.append("") + lines.append("[truncated: lower-priority memories omitted]") + lines.append(close_tag) + + rendered = "\n".join(lines) + if len(rendered) <= max_chars: + return rendered + return _fit_sections_to_total_budget( + fixed_lines, + rendered_sections, + close_tag, + max_chars, + SECTION_FIT_PRIORITY.get(wrapper, []), + ) + + +def _render_section(title, entries, budget, entry_max_chars): + lines = [title] + used = len(title) + truncated = False + for entry in entries: + line = _render_entry(entry, entry_max_chars) + addition = len(line) + 1 + if used + addition > budget: + truncated = True + break + lines.append(line) + used += addition + return (lines if len(lines) > 1 else []), truncated + + +def _render_entry(entry, max_chars): + date = _date_label(entry.get("createdAt")) + if entry["kind"] == "rawdata": + label = f"rawdata:{entry.get('id')}" + elif entry["kind"] == "insight": + label = f"insight:{entry.get('id')} {entry.get('category') or 'insight'}" + else: + label = f"item:{entry.get('id')} {entry.get('category') or 'memory'}" + if date: + label = f"{label}, {date}" + return f"- [{label}] {_clip(entry.get('text'), max_chars)}" + + +def _fit_sections_to_total_budget(fixed_lines, rendered_sections, close_tag, max_chars, priority): + notice = "[truncated: lower-priority memories omitted]" + full_suffix = f"\n{notice}\n{close_tag}" + suffix = full_suffix if len(full_suffix) < max_chars else f"\n{close_tag}" + selected_sections = [] + selected_keys = set() + section_map = {key: lines for key, lines in rendered_sections} + base = "\n".join(fixed_lines) + used = len(base) + budget = max(0, max_chars - len(suffix)) + + for key in priority + [key for key, _lines in rendered_sections if key not in priority]: + section_lines = section_map.get(key) + if not section_lines or key in selected_keys: + continue + addition = len("\n\n" + "\n".join(section_lines)) + if used + addition > budget: + break + selected_sections.append((key, section_lines)) + selected_keys.add(key) + used += addition + + selected_sections.sort(key=lambda item: [key for key, _title in SESSION_SECTION_ORDER + PROMPT_SECTION_ORDER].index(item[0]) if item[0] in {key for key, _title in SESSION_SECTION_ORDER + PROMPT_SECTION_ORDER} else 999) + selected = list(fixed_lines) + for _key, section_lines in selected_sections: + selected.append("") + selected.extend(section_lines) + prefix = "\n".join(selected) + if len(prefix) > budget: + prefix = prefix[:budget].rstrip() + result = f"{prefix}{suffix}" if prefix else suffix.lstrip() + if len(result) <= max_chars: + return result + overflow = len(result) - max_chars + prefix = prefix[:-overflow].rstrip() if overflow < len(prefix) else "" + return f"{prefix}{suffix}" if prefix else suffix.lstrip() + + +def _open_tag(wrapper, attrs): + if not attrs: + return f"<{wrapper}>" + rendered = " ".join( + f'{name}="{html.escape(str(value), quote=True)}"' for name, value in attrs.items() + ) + return f"<{wrapper} {rendered}>" + + +def _field(value, name): + if isinstance(value, dict): + return value.get(name) + return getattr(value, name, None) + + +def _clean(value): + return " ".join(str(value or "").split()) + + +def _clip(value, max_chars): + cleaned = _clean(value) + if len(cleaned) <= max_chars: + return cleaned + return cleaned[: max(0, max_chars - 12)].rstrip() + " [truncated]" + + +def _dedupe_key(value): + cleaned = _clean(value).lower() + cleaned = cleaned.translate(str.maketrans("", "", string.punctuation)) + cleaned = re.sub(r"\s+", " ", cleaned).strip() + return cleaned[:260] + + +def _timestamp(value): + if not value: + return 0 + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp() + except ValueError: + return 0 + + +def _date_label(value): + if not value: + return "" + text = str(value) + return text[:10] if len(text) >= 10 else text + + +def _number(*values): + for value in values: + if value is None: + continue + try: + return float(value) + except (TypeError, ValueError): + continue + return 0 diff --git a/memind-integrations/claude-code/tests/test_context_compiler.py b/memind-integrations/claude-code/tests/test_context_compiler.py new file mode 100644 index 00000000..d3800a76 --- /dev/null +++ b/memind-integrations/claude-code/tests/test_context_compiler.py @@ -0,0 +1,140 @@ +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +from scripts.lib.context_compiler import compile_session_start_context + + +class ContextCompilerTest(unittest.TestCase): + def test_session_start_context_ranks_dedupes_and_preserves_priority_sections(self): + context = { + "projectSlug": "memind-main", + "recentRawData": [ + { + "id": "rd-old", + "caption": "Older work on unrelated install docs.", + "createdAt": "2026-05-25T10:00:00Z", + "metadata": {}, + }, + { + "id": "rd-new", + "caption": "Completed SessionStart context injection for Claude Code and Codex; keep userId and agentId stable.", + "createdAt": "2026-05-27T10:00:00Z", + "metadata": {}, + }, + ], + "items": { + "directive": [ + { + "id": "dir-1", + "category": "directive", + "text": "Keep userId and agentId stable; use metadata.projectSlug for project isolation.", + "createdAt": "2026-05-27T09:00:00Z", + "metadata": {}, + } + ], + "watchOut": [ + { + "id": "res-1", + "category": "resolution", + "text": "Codex tests must run with Python 3.12; older Python can fail on modern type syntax.", + "createdAt": "2026-05-27T08:00:00Z", + "metadata": {}, + }, + { + "id": "res-dup", + "category": "resolution", + "text": "codex tests must run with python 3.12 older python can fail on modern type syntax", + "createdAt": "2026-05-26T08:00:00Z", + "metadata": {}, + }, + ], + "playbook": [ + { + "id": "pb-1", + "category": "playbook", + "text": "After changing Claude Code or Codex hooks, run both integration unittest suites and git diff --check.", + "createdAt": "2026-05-27T07:00:00Z", + "metadata": {}, + } + ], + "fact": [ + { + "id": "fact-1", + "category": "event", + "text": "SessionStart is read-only: it queries memory and injects context without writing rawdata.", + "createdAt": "2026-05-27T06:00:00Z", + "metadata": {}, + } + ], + }, + } + + rendered = compile_session_start_context(context, {"sessionContextMaxChars": 6000}) + + self.assertIn('', rendered) + self.assertIn("Historical Memind project memory", rendered) + self.assertIn("Current user instructions and repository files take precedence", rendered) + self.assertIn("Verify old implementation details against the working tree", rendered) + self.assertIn("## Continue From", rendered) + self.assertIn("[rawdata:rd-new, 2026-05-27] Completed SessionStart context injection", rendered) + self.assertLess(rendered.index("rd-new"), rendered.index("rd-old")) + self.assertIn("## Must Follow", rendered) + self.assertIn("[item:dir-1 directive, 2026-05-27] Keep userId and agentId stable", rendered) + self.assertIn("rd-new", rendered, "turn-summary caption should stay in Continue From") + self.assertIn("dir-1", rendered, "structured item should not be removed by caption text") + self.assertIn("## Watch Outs", rendered) + self.assertIn("[item:res-1 resolution, 2026-05-27] Codex tests must run", rendered) + self.assertNotIn("res-dup", rendered) + self.assertIn("## Reusable Playbooks", rendered) + self.assertIn("[item:pb-1 playbook, 2026-05-27] After changing Claude Code", rendered) + self.assertIn("## Useful Facts", rendered) + self.assertIn("[item:fact-1 event, 2026-05-27] SessionStart is read-only", rendered) + self.assertTrue(rendered.endswith("")) + + def test_session_start_context_uses_section_budget_instead_of_naive_line_truncation(self): + context = { + "projectSlug": "memind-main", + "recentRawData": [ + {"id": "rd-1", "caption": "Recent work " + "A" * 500, "createdAt": "2026-05-27T01:00:00Z"}, + ], + "items": { + "directive": [ + {"id": "dir-1", "category": "directive", "text": "Do not break stable identity.", "createdAt": "2026-05-27T01:00:00Z"} + ], + "watchOut": [ + {"id": "res-1", "category": "resolution", "text": "Always avoid committing __pycache__ files.", "createdAt": "2026-05-27T01:00:00Z"} + ], + "playbook": [ + {"id": "pb-1", "category": "playbook", "text": "Run focused integration tests after hook changes.", "createdAt": "2026-05-27T01:00:00Z"} + ], + "fact": [ + {"id": "fact-1", "category": "event", "text": "Fact " + "F" * 400, "createdAt": "2026-05-27T01:00:00Z"} + ], + }, + } + + rendered = compile_session_start_context(context, {"sessionContextMaxChars": 900}) + + self.assertLessEqual(len(rendered), 900) + self.assertIn("## Must Follow", rendered) + self.assertIn("Do not break stable identity.", rendered) + self.assertIn("## Watch Outs", rendered) + self.assertIn("Always avoid committing __pycache__", rendered) + self.assertIn("truncated: lower-priority memories omitted", rendered) + self.assertTrue(rendered.endswith("")) + + def test_session_start_context_returns_empty_when_no_entries_exist(self): + rendered = compile_session_start_context( + {"projectSlug": "memind-main", "recentRawData": [], "items": {}}, + {"sessionContextMaxChars": 6000}, + ) + + self.assertEqual(rendered, "") + + +if __name__ == "__main__": + unittest.main() From c8a1fe29834028b4ef8cc9033f8ade2fc4597780 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 10:42:22 +0800 Subject: [PATCH 31/54] feat: compile claude code session context --- .../scripts/lib/session_context.py | 74 +------------------ .../claude-code/tests/test_session_context.py | 5 +- 2 files changed, 6 insertions(+), 73 deletions(-) diff --git a/memind-integrations/claude-code/scripts/lib/session_context.py b/memind-integrations/claude-code/scripts/lib/session_context.py index ff8298b3..02241b48 100644 --- a/memind-integrations/claude-code/scripts/lib/session_context.py +++ b/memind-integrations/claude-code/scripts/lib/session_context.py @@ -12,20 +12,12 @@ # limitations under the License. # -import html +from lib.context_compiler import compile_session_start_context DEFAULT_RECENT_SESSIONS = 3 DEFAULT_MAX_ITEMS = 6 DEFAULT_MAX_CHARS = 6000 -SECTION_ORDER = [ - ("recentRawData", "## Continue From"), - ("directive", "## Must Follow"), - ("watchOut", "## Watch Outs"), - ("playbook", "## Reusable Playbooks"), - ("fact", "## Useful Facts"), -] - def project_metadata_filter(project_slug): return {"all": [{"path": "projectSlug", "op": "eq", "value": project_slug}]} @@ -89,33 +81,7 @@ def build_session_context(client, identity, project_slug, config): def render_session_context(context, config): - project_slug = context.get("projectSlug") or "unknown" - max_chars = int(config.get("sessionContextMaxChars", DEFAULT_MAX_CHARS)) - header = f'' - preamble = ( - "Memind project memory. Use only when directly helpful. Prefer explicit " - "user instructions and repository files over memory if they conflict." - ) - footer = "" - lines = [header, preamble] - - for key, title in SECTION_ORDER: - entries = _section_entries(context, key) - if not entries: - continue - lines.append("") - lines.append(title) - for entry in entries: - lines.append(_render_entry(key, entry)) - - if len(lines) <= 2: - return "" - - full = "\n".join(lines + [footer]) - if len(full) <= max_chars: - return full - - return _truncate_lines(lines, footer, max_chars) + return compile_session_start_context(context, config) def _query_items(client, identity, categories, metadata_filter, limit): @@ -149,38 +115,6 @@ def _item_entry(item): } -def _section_entries(context, key): - if key == "recentRawData": - return context.get("recentRawData") or [] - return (context.get("items") or {}).get(key) or [] - - -def _render_entry(section_key, entry): - if section_key == "recentRawData": - return f"- [rawdata:{entry.get('id')}] {_clean(entry.get('caption'))}" - category = entry.get("category") or "memory" - return f"- [item:{entry.get('id')} {category}] {_clean(entry.get('text'))}" - - -def _truncate_lines(lines, footer, max_chars): - notice = "\n[truncated]\n" - budget = max(0, max_chars - len(notice) - len(footer)) - selected = [] - used = 0 - for line in lines: - addition = len(line) + (1 if selected else 0) - if used + addition > budget: - break - selected.append(line) - used += addition - if len(selected) <= 2: - base = "\n".join(lines[:2]) - available = max(0, budget - len(base)) - base = base[:available] - return f"{base}{notice}{footer}"[:max_chars] - return f"{chr(10).join(selected)}{notice}{footer}"[:max_chars] - - def _field(value, name): if isinstance(value, dict): return value.get(name) @@ -189,7 +123,3 @@ def _field(value, name): def _text(item): return _field(item, "text") - - -def _clean(value): - return " ".join(str(value or "").split()) diff --git a/memind-integrations/claude-code/tests/test_session_context.py b/memind-integrations/claude-code/tests/test_session_context.py index b47ad585..c3b9e264 100644 --- a/memind-integrations/claude-code/tests/test_session_context.py +++ b/memind-integrations/claude-code/tests/test_session_context.py @@ -87,8 +87,11 @@ def test_build_session_context_queries_current_project_memory(self): rendered = render_session_context(context, {"sessionContextMaxChars": 4000}) self.assertIn('', rendered) + self.assertIn("Historical Memind project memory", rendered) + self.assertIn("Current user instructions and repository files take precedence", rendered) + self.assertIn("Verify old implementation details against the working tree", rendered) self.assertIn("## Continue From", rendered) - self.assertIn("[rawdata:rd-1] Implemented generic memory query APIs", rendered) + self.assertIn("[rawdata:rd-1, 2026-05-27] Implemented generic memory query APIs", rendered) self.assertIn("## Must Follow", rendered) self.assertIn("[item:it-1 directive] Keep userId and agentId stable.", rendered) self.assertIn("## Watch Outs", rendered) From 857c7476356a53a7cd89f42c21fb89ea240dcdb0 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 10:49:22 +0800 Subject: [PATCH 32/54] feat: compile claude code prompt retrieval context --- .../scripts/lib/context_compiler.py | 11 ++- .../claude-code/scripts/retrieve.py | 66 +----------------- .../tests/test_context_compiler.py | 68 +++++++++++++++++++ .../claude-code/tests/test_hooks.py | 6 +- 4 files changed, 83 insertions(+), 68 deletions(-) diff --git a/memind-integrations/claude-code/scripts/lib/context_compiler.py b/memind-integrations/claude-code/scripts/lib/context_compiler.py index a3b45611..7f5b3741 100644 --- a/memind-integrations/claude-code/scripts/lib/context_compiler.py +++ b/memind-integrations/claude-code/scripts/lib/context_compiler.py @@ -121,12 +121,13 @@ def compile_prompt_retrieval_context(data, config): insights = [_normalize_insight(insight) for insight in data.get("insights") or [] if _field(insight, "text")] sorted_items = _sort_prompt_items(items) + selected_insights = _select_prompt_insights(insights, _section_limit("insights", max_entries)) sections = { "directives": _top_category(sorted_items, "directive", _section_limit("directives", max_entries)), "resolvedProblems": _top_category(sorted_items, "resolution", _section_limit("resolvedProblems", max_entries)), "playbooks": _top_category(sorted_items, "playbook", _section_limit("playbooks", max_entries)), "toolNotes": _top_category(sorted_items, "tool", _section_limit("toolNotes", max_entries)), - "insights": _sort_insights(insights)[: _section_limit("insights", max_entries)], + "insights": selected_insights, "memoryItems": _top_general_items(sorted_items, _section_limit("memoryItems", max_entries)), } @@ -168,7 +169,7 @@ def _prepare_sections(sections, mode): prepared = {} high_value_seen = set() for key, entries in sections.items(): - ranked = _rank_entries(entries, key, mode) + ranked = list(entries) if mode == "prompt_retrieval" and key == "insights" else _rank_entries(entries, key, mode) deduped = [] section_seen = set() for entry in ranked: @@ -287,6 +288,12 @@ def _sort_insights(insights): ) +def _select_prompt_insights(insights, limit): + sorted_insights = _sort_insights(insights) + high_level = [entry for entry in sorted_insights if entry.get("category") in {"root", "branch"}] + return (high_level or sorted_insights)[:limit] + + def _top_category(items, category, limit): return [entry for entry in items if entry["category"] == category][:limit] diff --git a/memind-integrations/claude-code/scripts/retrieve.py b/memind-integrations/claude-code/scripts/retrieve.py index af7c6906..bde202de 100644 --- a/memind-integrations/claude-code/scripts/retrieve.py +++ b/memind-integrations/claude-code/scripts/retrieve.py @@ -22,6 +22,7 @@ from lib.client import MemindClient from lib.agent_timeline import normalize_user_prompt_event from lib.config import load_config +from lib.context_compiler import compile_prompt_retrieval_context from lib.content import read_recent_context from lib.identity import resolve_identity from lib.logging_utils import debug_log @@ -29,71 +30,8 @@ from ingest import state_root -AGENT_CATEGORY_SECTIONS = [ - ("playbook", "## Agent Playbooks"), - ("resolution", "## Resolved Problems"), - ("tool", "## Tool Notes"), - ("directive", "## Directives"), -] - - def _format_context(data, config): - max_entries = int(config.get("retrieveMaxEntries", 8)) - max_chars = int(config.get("retrieveMaxChars", 6000)) - tier_rank = {"ROOT": 0, "BRANCH": 1, "LEAF": 2} - - insights = [insight for insight in (data.get("insights") or []) if insight.get("text")] - insights.sort(key=lambda insight: (tier_rank.get(str(insight.get("tier", "LEAF")).upper(), 2), str(insight.get("id", "")))) - high_level = [insight for insight in insights if str(insight.get("tier", "")).upper() in {"ROOT", "BRANCH"}] - selected_insights = (high_level or insights)[: min(3, max_entries)] - - remaining = max_entries - len(selected_insights) - items = [item for item in (data.get("items") or []) if item.get("text")] - items.sort( - key=lambda item: item.get("finalScore") if item.get("finalScore") is not None else item.get("vectorScore", 0), - reverse=True, - ) - selected_items = items[: max(0, remaining)] - - sections = [] - if selected_insights: - sections.append("## Insights") - sections.extend(f"- [insight:{insight.get('id')}] {insight.get('text')}" for insight in selected_insights) - grouped_agent_items = _group_agent_items(selected_items) - for category, header in AGENT_CATEGORY_SECTIONS: - category_items = grouped_agent_items.get(category, []) - if category_items: - if sections: - sections.append("") - sections.append(header) - sections.extend(f"- [item:{item.get('id')}] {item.get('text')}" for item in category_items) - general_items = [item for item in selected_items if _item_category(item) not in grouped_agent_items] - if general_items: - if sections: - sections.append("") - sections.append("## Memory Items") - sections.extend(f"- [item:{item.get('id')}] {item.get('text')}" for item in general_items) - degraded_notice = "" - if data.get("status") == "degraded": - degraded_notice = "\n[Note: Memory retrieval encountered an error. Results may be incomplete.]\n" - if not sections and not degraded_notice: - return "" - body = "\n".join(sections)[:max_chars] - return f"\n{config.get('retrievePromptPreamble') or ''}\n{body}{degraded_notice}\n" - - -def _item_category(item): - return str(item.get("category") or "").strip().lower() - - -def _group_agent_items(items): - agent_categories = {category for category, _header in AGENT_CATEGORY_SECTIONS} - grouped = {} - for item in items: - category = _item_category(item) - if category in agent_categories: - grouped.setdefault(category, []).append(item) - return grouped + return compile_prompt_retrieval_context(data, config) def main(): diff --git a/memind-integrations/claude-code/tests/test_context_compiler.py b/memind-integrations/claude-code/tests/test_context_compiler.py index d3800a76..0579ec18 100644 --- a/memind-integrations/claude-code/tests/test_context_compiler.py +++ b/memind-integrations/claude-code/tests/test_context_compiler.py @@ -135,6 +135,74 @@ def test_session_start_context_returns_empty_when_no_entries_exist(self): self.assertEqual(rendered, "") + def test_prompt_retrieval_context_groups_agent_categories_by_execution_value(self): + from scripts.lib.context_compiler import compile_prompt_retrieval_context + + data = { + "insights": [ + {"id": "ins-leaf", "text": "Leaf insight", "tier": "LEAF"}, + {"id": "ins-root", "text": "Root insight", "tier": "ROOT"}, + ], + "items": [ + {"id": "tool-1", "text": "Use mvn -pl memind-server test for server checks.", "category": "tool", "finalScore": 0.6}, + {"id": "res-1", "text": "Retry spool events are cleared only after successful agent_timeline extraction.", "category": "resolution", "finalScore": 0.9}, + {"id": "pb-1", "text": "When hooks change, run both integration test suites.", "category": "playbook", "finalScore": 0.8}, + {"id": "dir-1", "text": "Do not default Claude Code or Codex to conversation rawdata.", "category": "directive", "finalScore": 0.7}, + {"id": "ev-1", "text": "rawdata-agent emits agent_episode segment metadata.", "category": "event", "finalScore": 0.5}, + {"id": "ev-high", "text": "A high-scoring general fact should not crowd out agent-specific sections.", "category": "event", "finalScore": 0.99}, + ], + } + + rendered = compile_prompt_retrieval_context( + data, + {"retrieveMaxEntries": 8, "retrieveMaxChars": 6000, "retrievePromptPreamble": "Relevant memories from Memind."}, + ) + + self.assertIn("", rendered) + self.assertIn("## Directives", rendered) + self.assertIn("[item:dir-1 directive] Do not default Claude Code", rendered) + self.assertIn("## Resolved Problems", rendered) + self.assertIn("[item:res-1 resolution] Retry spool events", rendered) + self.assertIn("## Agent Playbooks", rendered) + self.assertIn("[item:pb-1 playbook] When hooks change", rendered) + self.assertIn("## Tool Notes", rendered) + self.assertIn("[item:tool-1 tool] Use mvn", rendered) + self.assertIn("## Insights", rendered) + self.assertIn("[insight:ins-root root] Root insight", rendered) + self.assertNotIn("ins-leaf", rendered) + self.assertIn("## Memory Items", rendered) + self.assertIn("[item:ev-high event] A high-scoring general fact", rendered) + self.assertIn("[item:ev-1 event] rawdata-agent emits", rendered) + self.assertTrue(rendered.endswith("")) + + def test_prompt_retrieval_context_preserves_insight_tier_order(self): + from scripts.lib.context_compiler import compile_prompt_retrieval_context + + rendered = compile_prompt_retrieval_context( + { + "insights": [ + {"id": "leaf", "text": "Leaf memory", "tier": "LEAF"}, + {"id": "root", "text": "Root memory", "tier": "ROOT"}, + {"id": "branch", "text": "Branch memory", "tier": "BRANCH"}, + ] + }, + {"retrieveMaxEntries": 8, "retrieveMaxChars": 6000, "retrievePromptPreamble": ""}, + ) + + self.assertLess(rendered.index("insight:root"), rendered.index("insight:branch")) + self.assertNotIn("insight:leaf", rendered) + + def test_prompt_retrieval_context_keeps_degraded_notice(self): + from scripts.lib.context_compiler import compile_prompt_retrieval_context + + rendered = compile_prompt_retrieval_context( + {"status": "degraded"}, + {"retrieveMaxEntries": 8, "retrieveMaxChars": 1000, "retrievePromptPreamble": ""}, + ) + + self.assertIn("Memory retrieval encountered an error", rendered) + self.assertIn("", rendered) + if __name__ == "__main__": unittest.main() diff --git a/memind-integrations/claude-code/tests/test_hooks.py b/memind-integrations/claude-code/tests/test_hooks.py index 2695d488..038b2ef8 100644 --- a/memind-integrations/claude-code/tests/test_hooks.py +++ b/memind-integrations/claude-code/tests/test_hooks.py @@ -60,9 +60,9 @@ def test_format_context_prioritizes_tiers_and_scores(self): context = _format_context(data, {"retrieveMaxEntries": 4, "retrieveMaxChars": 1000, "retrievePromptPreamble": "P"}) self.assertIn("## Insights", context) self.assertIn("## Memory Items", context) - self.assertLess(context.index("[insight:1] root"), context.index("[insight:2] branch")) + self.assertLess(context.index("[insight:1 root] root"), context.index("[insight:2 branch] branch")) self.assertNotIn("leaf", context) - self.assertLess(context.index("[item:11] high"), context.index("[item:10] low")) + self.assertLess(context.index("[item:11 memory] high"), context.index("[item:10 memory] low")) def test_format_context_includes_degraded_notice_without_results(self): sys.path.insert(0, str(ROOT / "scripts")) @@ -115,6 +115,8 @@ def test_format_context_groups_agent_memory_categories(self): self.assertIn("## Resolved Problems", context) self.assertIn("## Tool Notes", context) self.assertIn("## Directives", context) + self.assertLess(context.index("## Directives"), context.index("## Resolved Problems")) + self.assertLess(context.index("## Resolved Problems"), context.index("## Agent Playbooks")) self.assertNotIn("## Memory Items", context) def test_ingest_without_transcript_fails_open(self): From 789a22ace87307003facdb6023a694155ee6a328 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 10:51:00 +0800 Subject: [PATCH 33/54] feat: add codex agent context compiler --- .../codex/scripts/lib/context_compiler.py | 506 ++++++++++++++++++ .../codex/tests/test_context_compiler.py | 214 ++++++++ 2 files changed, 720 insertions(+) create mode 100644 memind-integrations/codex/scripts/lib/context_compiler.py create mode 100644 memind-integrations/codex/tests/test_context_compiler.py diff --git a/memind-integrations/codex/scripts/lib/context_compiler.py b/memind-integrations/codex/scripts/lib/context_compiler.py new file mode 100644 index 00000000..7f5b3741 --- /dev/null +++ b/memind-integrations/codex/scripts/lib/context_compiler.py @@ -0,0 +1,506 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import html +import re +import string +from datetime import datetime + +DEFAULT_MAX_CHARS = 6000 +DEFAULT_SESSION_ENTRY_MAX_CHARS = 520 +DEFAULT_RETRIEVAL_ENTRY_MAX_CHARS = 700 + +SESSION_SECTION_ORDER = [ + ("continueFrom", "## Continue From"), + ("mustFollow", "## Must Follow"), + ("watchOuts", "## Watch Outs"), + ("playbooks", "## Reusable Playbooks"), + ("facts", "## Useful Facts"), +] + +SESSION_SECTION_BUDGETS = { + "continueFrom": 1300, + "mustFollow": 1300, + "watchOuts": 1400, + "playbooks": 1200, + "facts": 800, +} + +PROMPT_SECTION_ORDER = [ + ("directives", "## Directives"), + ("resolvedProblems", "## Resolved Problems"), + ("playbooks", "## Agent Playbooks"), + ("toolNotes", "## Tool Notes"), + ("insights", "## Insights"), + ("memoryItems", "## Memory Items"), +] + +PROMPT_SECTION_BUDGETS = { + "directives": 900, + "resolvedProblems": 1600, + "playbooks": 1200, + "toolNotes": 900, + "insights": 800, + "memoryItems": 600, +} + +PROMPT_SECTION_LIMITS = { + "directives": 3, + "resolvedProblems": 5, + "playbooks": 4, + "toolNotes": 3, + "insights": 3, + "memoryItems": 3, +} + +SECTION_FIT_PRIORITY = { + "memind_session_context": ["mustFollow", "watchOuts", "continueFrom", "playbooks", "facts"], + "memind_memories": ["directives", "resolvedProblems", "playbooks", "toolNotes", "insights", "memoryItems"], +} + +WATCH_OUT_TERMS = { + "error", + "failed", + "failure", + "fix", + "fixed", + "regression", + "test", + "timeout", + "retry", + "avoid", +} + +PLAYBOOK_TERMS = {"run", "after", "before", "when", "then", "workflow", "steps", "verify"} + + +def compile_session_start_context(context, config): + sections = { + "continueFrom": _normalize_rawdata(context.get("recentRawData") or []), + "mustFollow": _normalize_items(((context.get("items") or {}).get("directive") or []), "mustFollow"), + "watchOuts": _normalize_items(((context.get("items") or {}).get("watchOut") or []), "watchOuts"), + "playbooks": _normalize_items(((context.get("items") or {}).get("playbook") or []), "playbooks"), + "facts": _normalize_items(((context.get("items") or {}).get("fact") or []), "facts"), + } + project_slug = context.get("projectSlug") or "unknown" + max_chars = int(config.get("sessionContextMaxChars", DEFAULT_MAX_CHARS)) + entry_max_chars = int(config.get("sessionContextEntryMaxChars", DEFAULT_SESSION_ENTRY_MAX_CHARS)) + return _render_context( + wrapper="memind_session_context", + attrs={"project": project_slug}, + preamble=( + "Historical Memind project memory. Use only when directly helpful. " + "Current user instructions and repository files take precedence. " + "Verify old implementation details against the working tree before relying on them." + ), + sections=_prepare_sections(sections, "session_start"), + order=SESSION_SECTION_ORDER, + budgets=SESSION_SECTION_BUDGETS, + max_chars=max_chars, + entry_max_chars=entry_max_chars, + ) + + +def compile_prompt_retrieval_context(data, config): + max_entries = int(config.get("retrieveMaxEntries", 8)) + max_chars = int(config.get("retrieveMaxChars", DEFAULT_MAX_CHARS)) + entry_max_chars = int(config.get("retrieveEntryMaxChars", DEFAULT_RETRIEVAL_ENTRY_MAX_CHARS)) + + items = [_normalize_retrieved_item(item) for item in data.get("items") or [] if _field(item, "text")] + insights = [_normalize_insight(insight) for insight in data.get("insights") or [] if _field(insight, "text")] + sorted_items = _sort_prompt_items(items) + + selected_insights = _select_prompt_insights(insights, _section_limit("insights", max_entries)) + sections = { + "directives": _top_category(sorted_items, "directive", _section_limit("directives", max_entries)), + "resolvedProblems": _top_category(sorted_items, "resolution", _section_limit("resolvedProblems", max_entries)), + "playbooks": _top_category(sorted_items, "playbook", _section_limit("playbooks", max_entries)), + "toolNotes": _top_category(sorted_items, "tool", _section_limit("toolNotes", max_entries)), + "insights": selected_insights, + "memoryItems": _top_general_items(sorted_items, _section_limit("memoryItems", max_entries)), + } + + degraded_notice = "" + if data.get("status") == "degraded": + degraded_notice = "[Note: Memory retrieval encountered an error. Results may be incomplete.]" + + preamble = config.get("retrievePromptPreamble") or ( + "Relevant Memind memories for the current request. Use only when directly helpful." + ) + rendered = _render_context( + wrapper="memind_memories", + attrs={}, + preamble=preamble, + sections=_prepare_sections(sections, "prompt_retrieval"), + order=PROMPT_SECTION_ORDER, + budgets=PROMPT_SECTION_BUDGETS, + max_chars=max_chars, + entry_max_chars=entry_max_chars, + trailing_notice=degraded_notice, + ) + if rendered or not degraded_notice: + return rendered + return _render_context( + wrapper="memind_memories", + attrs={}, + preamble=preamble, + sections={}, + order=PROMPT_SECTION_ORDER, + budgets=PROMPT_SECTION_BUDGETS, + max_chars=max_chars, + entry_max_chars=entry_max_chars, + trailing_notice=degraded_notice, + allow_notice_only=True, + ) + + +def _prepare_sections(sections, mode): + prepared = {} + high_value_seen = set() + for key, entries in sections.items(): + ranked = list(entries) if mode == "prompt_retrieval" and key == "insights" else _rank_entries(entries, key, mode) + deduped = [] + section_seen = set() + for entry in ranked: + dedupe_key = _dedupe_key(entry["text"]) + if not dedupe_key or dedupe_key in section_seen: + continue + if key in {"facts", "memoryItems"} and dedupe_key in high_value_seen: + continue + section_seen.add(dedupe_key) + deduped.append(entry) + if key not in {"continueFrom", "facts", "memoryItems"}: + high_value_seen.update(_dedupe_key(entry["text"]) for entry in deduped if _dedupe_key(entry["text"])) + if deduped: + prepared[key] = deduped + return prepared + + +def _normalize_rawdata(raw_data): + entries = [] + for raw in raw_data: + text = _field(raw, "caption") + if not text: + continue + entries.append( + { + "kind": "rawdata", + "id": _field(raw, "id"), + "category": "agent_timeline", + "text": _clean(text), + "createdAt": _field(raw, "createdAt") or _field(raw, "created_at"), + "score": 0, + } + ) + return entries + + +def _normalize_items(items, section): + entries = [] + for item in items: + text = _field(item, "text") + if not text: + continue + category = str(_field(item, "category") or "memory").strip().lower() + entries.append( + { + "kind": "item", + "id": _field(item, "id"), + "category": category, + "text": _clean(text), + "createdAt": _field(item, "createdAt") or _field(item, "created_at"), + "score": _section_score(section, text), + } + ) + return entries + + +def _normalize_retrieved_item(item): + category = str(_field(item, "category") or "memory").strip().lower() + return { + "kind": "item", + "id": _field(item, "id"), + "category": category, + "text": _clean(_field(item, "text")), + "createdAt": _field(item, "createdAt") or _field(item, "created_at"), + "score": _number(_field(item, "finalScore"), _field(item, "vectorScore"), 0), + } + + +def _normalize_insight(insight): + return { + "kind": "insight", + "id": _field(insight, "id"), + "category": str(_field(insight, "tier") or "insight").strip().lower(), + "text": _clean(_field(insight, "text")), + "createdAt": _field(insight, "createdAt") or _field(insight, "created_at"), + "score": 0, + } + + +def _rank_entries(entries, section, mode): + if section == "continueFrom": + return sorted(entries, key=lambda entry: _timestamp(entry.get("createdAt")), reverse=True)[:3] + return sorted( + entries, + key=lambda entry: ( + entry.get("score", 0), + _timestamp(entry.get("createdAt")), + -len(entry.get("text", "")), + ), + reverse=True, + ) + + +def _sort_prompt_items(items): + return sorted( + items, + key=lambda entry: ( + entry.get("score", 0), + _timestamp(entry.get("createdAt")), + _category_priority(entry.get("category")), + ), + reverse=True, + ) + + +def _sort_insights(insights): + tier_rank = {"root": 3, "branch": 2, "leaf": 1} + return sorted( + insights, + key=lambda entry: ( + tier_rank.get(entry.get("category", ""), 0), + _timestamp(entry.get("createdAt")), + str(entry.get("id") or ""), + ), + reverse=True, + ) + + +def _select_prompt_insights(insights, limit): + sorted_insights = _sort_insights(insights) + high_level = [entry for entry in sorted_insights if entry.get("category") in {"root", "branch"}] + return (high_level or sorted_insights)[:limit] + + +def _top_category(items, category, limit): + return [entry for entry in items if entry["category"] == category][:limit] + + +def _top_general_items(items, limit): + agent_categories = {"directive", "resolution", "playbook", "tool"} + return [entry for entry in items if entry["category"] not in agent_categories][:limit] + + +def _section_limit(section, max_entries): + return max(0, min(PROMPT_SECTION_LIMITS.get(section, max_entries), max_entries)) + + +def _category_priority(category): + return {"directive": 5, "resolution": 4, "playbook": 3, "tool": 2}.get(category or "", 1) + + +def _section_score(section, text): + lowered = str(text or "").lower() + if section == "watchOuts": + return sum(1 for term in WATCH_OUT_TERMS if term in lowered) + if section == "playbooks": + return sum(1 for term in PLAYBOOK_TERMS if term in lowered) + if section == "mustFollow": + return 2 if len(lowered) <= 220 else 1 + return 0 + + +def _render_context( + wrapper, + attrs, + preamble, + sections, + order, + budgets, + max_chars, + entry_max_chars, + trailing_notice="", + allow_notice_only=False, +): + if not sections and not trailing_notice and not allow_notice_only: + return "" + + open_tag = _open_tag(wrapper, attrs) + close_tag = f"" + fixed_lines = [open_tag, preamble] + rendered_sections = [] + truncated = False + + for key, title in order: + entries = sections.get(key) or [] + if not entries: + continue + budget = budgets.get(key, 800) + lines, section_truncated = _render_section(title, entries, budget, entry_max_chars) + truncated = truncated or section_truncated + if lines: + rendered_sections.append((key, lines)) + + if trailing_notice: + rendered_sections.append(("notice", [trailing_notice])) + + if not rendered_sections and not allow_notice_only: + return "" + + lines = list(fixed_lines) + for _key, section_lines in rendered_sections: + lines.append("") + lines.extend(section_lines) + if truncated: + lines.append("") + lines.append("[truncated: lower-priority memories omitted]") + lines.append(close_tag) + + rendered = "\n".join(lines) + if len(rendered) <= max_chars: + return rendered + return _fit_sections_to_total_budget( + fixed_lines, + rendered_sections, + close_tag, + max_chars, + SECTION_FIT_PRIORITY.get(wrapper, []), + ) + + +def _render_section(title, entries, budget, entry_max_chars): + lines = [title] + used = len(title) + truncated = False + for entry in entries: + line = _render_entry(entry, entry_max_chars) + addition = len(line) + 1 + if used + addition > budget: + truncated = True + break + lines.append(line) + used += addition + return (lines if len(lines) > 1 else []), truncated + + +def _render_entry(entry, max_chars): + date = _date_label(entry.get("createdAt")) + if entry["kind"] == "rawdata": + label = f"rawdata:{entry.get('id')}" + elif entry["kind"] == "insight": + label = f"insight:{entry.get('id')} {entry.get('category') or 'insight'}" + else: + label = f"item:{entry.get('id')} {entry.get('category') or 'memory'}" + if date: + label = f"{label}, {date}" + return f"- [{label}] {_clip(entry.get('text'), max_chars)}" + + +def _fit_sections_to_total_budget(fixed_lines, rendered_sections, close_tag, max_chars, priority): + notice = "[truncated: lower-priority memories omitted]" + full_suffix = f"\n{notice}\n{close_tag}" + suffix = full_suffix if len(full_suffix) < max_chars else f"\n{close_tag}" + selected_sections = [] + selected_keys = set() + section_map = {key: lines for key, lines in rendered_sections} + base = "\n".join(fixed_lines) + used = len(base) + budget = max(0, max_chars - len(suffix)) + + for key in priority + [key for key, _lines in rendered_sections if key not in priority]: + section_lines = section_map.get(key) + if not section_lines or key in selected_keys: + continue + addition = len("\n\n" + "\n".join(section_lines)) + if used + addition > budget: + break + selected_sections.append((key, section_lines)) + selected_keys.add(key) + used += addition + + selected_sections.sort(key=lambda item: [key for key, _title in SESSION_SECTION_ORDER + PROMPT_SECTION_ORDER].index(item[0]) if item[0] in {key for key, _title in SESSION_SECTION_ORDER + PROMPT_SECTION_ORDER} else 999) + selected = list(fixed_lines) + for _key, section_lines in selected_sections: + selected.append("") + selected.extend(section_lines) + prefix = "\n".join(selected) + if len(prefix) > budget: + prefix = prefix[:budget].rstrip() + result = f"{prefix}{suffix}" if prefix else suffix.lstrip() + if len(result) <= max_chars: + return result + overflow = len(result) - max_chars + prefix = prefix[:-overflow].rstrip() if overflow < len(prefix) else "" + return f"{prefix}{suffix}" if prefix else suffix.lstrip() + + +def _open_tag(wrapper, attrs): + if not attrs: + return f"<{wrapper}>" + rendered = " ".join( + f'{name}="{html.escape(str(value), quote=True)}"' for name, value in attrs.items() + ) + return f"<{wrapper} {rendered}>" + + +def _field(value, name): + if isinstance(value, dict): + return value.get(name) + return getattr(value, name, None) + + +def _clean(value): + return " ".join(str(value or "").split()) + + +def _clip(value, max_chars): + cleaned = _clean(value) + if len(cleaned) <= max_chars: + return cleaned + return cleaned[: max(0, max_chars - 12)].rstrip() + " [truncated]" + + +def _dedupe_key(value): + cleaned = _clean(value).lower() + cleaned = cleaned.translate(str.maketrans("", "", string.punctuation)) + cleaned = re.sub(r"\s+", " ", cleaned).strip() + return cleaned[:260] + + +def _timestamp(value): + if not value: + return 0 + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp() + except ValueError: + return 0 + + +def _date_label(value): + if not value: + return "" + text = str(value) + return text[:10] if len(text) >= 10 else text + + +def _number(*values): + for value in values: + if value is None: + continue + try: + return float(value) + except (TypeError, ValueError): + continue + return 0 diff --git a/memind-integrations/codex/tests/test_context_compiler.py b/memind-integrations/codex/tests/test_context_compiler.py new file mode 100644 index 00000000..647ec682 --- /dev/null +++ b/memind-integrations/codex/tests/test_context_compiler.py @@ -0,0 +1,214 @@ +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +from scripts.lib.context_compiler import compile_session_start_context + + +class ContextCompilerTest(unittest.TestCase): + def test_compiler_exports_match_claude_code_contract(self): + import scripts.lib.context_compiler as compiler + + self.assertTrue(callable(compiler.compile_session_start_context)) + self.assertTrue(callable(compiler.compile_prompt_retrieval_context)) + + def test_session_start_context_ranks_dedupes_and_preserves_priority_sections(self): + context = { + "projectSlug": "memind-main", + "recentRawData": [ + { + "id": "rd-old", + "caption": "Older work on unrelated install docs.", + "createdAt": "2026-05-25T10:00:00Z", + "metadata": {}, + }, + { + "id": "rd-new", + "caption": "Completed SessionStart context injection for Claude Code and Codex; keep userId and agentId stable.", + "createdAt": "2026-05-27T10:00:00Z", + "metadata": {}, + }, + ], + "items": { + "directive": [ + { + "id": "dir-1", + "category": "directive", + "text": "Keep userId and agentId stable; use metadata.projectSlug for project isolation.", + "createdAt": "2026-05-27T09:00:00Z", + "metadata": {}, + } + ], + "watchOut": [ + { + "id": "res-1", + "category": "resolution", + "text": "Codex tests must run with Python 3.12; older Python can fail on modern type syntax.", + "createdAt": "2026-05-27T08:00:00Z", + "metadata": {}, + }, + { + "id": "res-dup", + "category": "resolution", + "text": "codex tests must run with python 3.12 older python can fail on modern type syntax", + "createdAt": "2026-05-26T08:00:00Z", + "metadata": {}, + }, + ], + "playbook": [ + { + "id": "pb-1", + "category": "playbook", + "text": "After changing Claude Code or Codex hooks, run both integration unittest suites and git diff --check.", + "createdAt": "2026-05-27T07:00:00Z", + "metadata": {}, + } + ], + "fact": [ + { + "id": "fact-1", + "category": "event", + "text": "SessionStart is read-only: it queries memory and injects context without writing rawdata.", + "createdAt": "2026-05-27T06:00:00Z", + "metadata": {}, + } + ], + }, + } + + rendered = compile_session_start_context(context, {"sessionContextMaxChars": 6000}) + + self.assertIn('', rendered) + self.assertIn("Historical Memind project memory", rendered) + self.assertIn("Current user instructions and repository files take precedence", rendered) + self.assertIn("Verify old implementation details against the working tree", rendered) + self.assertIn("## Continue From", rendered) + self.assertIn("[rawdata:rd-new, 2026-05-27] Completed SessionStart context injection", rendered) + self.assertLess(rendered.index("rd-new"), rendered.index("rd-old")) + self.assertIn("## Must Follow", rendered) + self.assertIn("[item:dir-1 directive, 2026-05-27] Keep userId and agentId stable", rendered) + self.assertIn("rd-new", rendered, "turn-summary caption should stay in Continue From") + self.assertIn("dir-1", rendered, "structured item should not be removed by caption text") + self.assertIn("## Watch Outs", rendered) + self.assertIn("[item:res-1 resolution, 2026-05-27] Codex tests must run", rendered) + self.assertNotIn("res-dup", rendered) + self.assertIn("## Reusable Playbooks", rendered) + self.assertIn("[item:pb-1 playbook, 2026-05-27] After changing Claude Code", rendered) + self.assertIn("## Useful Facts", rendered) + self.assertIn("[item:fact-1 event, 2026-05-27] SessionStart is read-only", rendered) + self.assertTrue(rendered.endswith("")) + + def test_session_start_context_uses_section_budget_instead_of_naive_line_truncation(self): + context = { + "projectSlug": "memind-main", + "recentRawData": [ + {"id": "rd-1", "caption": "Recent work " + "A" * 500, "createdAt": "2026-05-27T01:00:00Z"}, + ], + "items": { + "directive": [ + {"id": "dir-1", "category": "directive", "text": "Do not break stable identity.", "createdAt": "2026-05-27T01:00:00Z"} + ], + "watchOut": [ + {"id": "res-1", "category": "resolution", "text": "Always avoid committing __pycache__ files.", "createdAt": "2026-05-27T01:00:00Z"} + ], + "playbook": [ + {"id": "pb-1", "category": "playbook", "text": "Run focused integration tests after hook changes.", "createdAt": "2026-05-27T01:00:00Z"} + ], + "fact": [ + {"id": "fact-1", "category": "event", "text": "Fact " + "F" * 400, "createdAt": "2026-05-27T01:00:00Z"} + ], + }, + } + + rendered = compile_session_start_context(context, {"sessionContextMaxChars": 900}) + + self.assertLessEqual(len(rendered), 900) + self.assertIn("## Must Follow", rendered) + self.assertIn("Do not break stable identity.", rendered) + self.assertIn("## Watch Outs", rendered) + self.assertIn("Always avoid committing __pycache__", rendered) + self.assertIn("truncated: lower-priority memories omitted", rendered) + self.assertTrue(rendered.endswith("")) + + def test_session_start_context_returns_empty_when_no_entries_exist(self): + rendered = compile_session_start_context( + {"projectSlug": "memind-main", "recentRawData": [], "items": {}}, + {"sessionContextMaxChars": 6000}, + ) + + self.assertEqual(rendered, "") + + def test_prompt_retrieval_context_groups_agent_categories_by_execution_value(self): + from scripts.lib.context_compiler import compile_prompt_retrieval_context + + data = { + "insights": [ + {"id": "ins-leaf", "text": "Leaf insight", "tier": "LEAF"}, + {"id": "ins-root", "text": "Root insight", "tier": "ROOT"}, + ], + "items": [ + {"id": "tool-1", "text": "Use mvn -pl memind-server test for server checks.", "category": "tool", "finalScore": 0.6}, + {"id": "res-1", "text": "Retry spool events are cleared only after successful agent_timeline extraction.", "category": "resolution", "finalScore": 0.9}, + {"id": "pb-1", "text": "When hooks change, run both integration test suites.", "category": "playbook", "finalScore": 0.8}, + {"id": "dir-1", "text": "Do not default Claude Code or Codex to conversation rawdata.", "category": "directive", "finalScore": 0.7}, + {"id": "ev-1", "text": "rawdata-agent emits agent_episode segment metadata.", "category": "event", "finalScore": 0.5}, + {"id": "ev-high", "text": "A high-scoring general fact should not crowd out agent-specific sections.", "category": "event", "finalScore": 0.99}, + ], + } + + rendered = compile_prompt_retrieval_context( + data, + {"retrieveMaxEntries": 8, "retrieveMaxChars": 6000, "retrievePromptPreamble": "Relevant memories from Memind."}, + ) + + self.assertIn("", rendered) + self.assertIn("## Directives", rendered) + self.assertIn("[item:dir-1 directive] Do not default Claude Code", rendered) + self.assertIn("## Resolved Problems", rendered) + self.assertIn("[item:res-1 resolution] Retry spool events", rendered) + self.assertIn("## Agent Playbooks", rendered) + self.assertIn("[item:pb-1 playbook] When hooks change", rendered) + self.assertIn("## Tool Notes", rendered) + self.assertIn("[item:tool-1 tool] Use mvn", rendered) + self.assertIn("## Insights", rendered) + self.assertIn("[insight:ins-root root] Root insight", rendered) + self.assertNotIn("ins-leaf", rendered) + self.assertIn("## Memory Items", rendered) + self.assertIn("[item:ev-high event] A high-scoring general fact", rendered) + self.assertIn("[item:ev-1 event] rawdata-agent emits", rendered) + self.assertTrue(rendered.endswith("")) + + def test_prompt_retrieval_context_preserves_insight_tier_order(self): + from scripts.lib.context_compiler import compile_prompt_retrieval_context + + rendered = compile_prompt_retrieval_context( + { + "insights": [ + {"id": "leaf", "text": "Leaf memory", "tier": "LEAF"}, + {"id": "root", "text": "Root memory", "tier": "ROOT"}, + {"id": "branch", "text": "Branch memory", "tier": "BRANCH"}, + ] + }, + {"retrieveMaxEntries": 8, "retrieveMaxChars": 6000, "retrievePromptPreamble": ""}, + ) + + self.assertLess(rendered.index("insight:root"), rendered.index("insight:branch")) + self.assertNotIn("insight:leaf", rendered) + + def test_prompt_retrieval_context_keeps_degraded_notice(self): + from scripts.lib.context_compiler import compile_prompt_retrieval_context + + rendered = compile_prompt_retrieval_context( + {"status": "degraded"}, + {"retrieveMaxEntries": 8, "retrieveMaxChars": 1000, "retrievePromptPreamble": ""}, + ) + + self.assertIn("Memory retrieval encountered an error", rendered) + self.assertIn("", rendered) + + +if __name__ == "__main__": + unittest.main() From dd8c1cc1dd6f34b998e9b5d8913334e98ed2f248 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 10:54:11 +0800 Subject: [PATCH 34/54] feat: compile codex memory context --- .../codex/scripts/lib/session_context.py | 74 +------------------ memind-integrations/codex/scripts/retrieve.py | 66 +---------------- memind-integrations/codex/tests/test_hooks.py | 6 +- .../codex/tests/test_session_context.py | 12 ++- 4 files changed, 18 insertions(+), 140 deletions(-) diff --git a/memind-integrations/codex/scripts/lib/session_context.py b/memind-integrations/codex/scripts/lib/session_context.py index ff8298b3..b6769046 100644 --- a/memind-integrations/codex/scripts/lib/session_context.py +++ b/memind-integrations/codex/scripts/lib/session_context.py @@ -12,21 +12,12 @@ # limitations under the License. # -import html +from lib.context_compiler import compile_session_start_context DEFAULT_RECENT_SESSIONS = 3 DEFAULT_MAX_ITEMS = 6 DEFAULT_MAX_CHARS = 6000 -SECTION_ORDER = [ - ("recentRawData", "## Continue From"), - ("directive", "## Must Follow"), - ("watchOut", "## Watch Outs"), - ("playbook", "## Reusable Playbooks"), - ("fact", "## Useful Facts"), -] - - def project_metadata_filter(project_slug): return {"all": [{"path": "projectSlug", "op": "eq", "value": project_slug}]} @@ -89,33 +80,7 @@ def build_session_context(client, identity, project_slug, config): def render_session_context(context, config): - project_slug = context.get("projectSlug") or "unknown" - max_chars = int(config.get("sessionContextMaxChars", DEFAULT_MAX_CHARS)) - header = f'' - preamble = ( - "Memind project memory. Use only when directly helpful. Prefer explicit " - "user instructions and repository files over memory if they conflict." - ) - footer = "" - lines = [header, preamble] - - for key, title in SECTION_ORDER: - entries = _section_entries(context, key) - if not entries: - continue - lines.append("") - lines.append(title) - for entry in entries: - lines.append(_render_entry(key, entry)) - - if len(lines) <= 2: - return "" - - full = "\n".join(lines + [footer]) - if len(full) <= max_chars: - return full - - return _truncate_lines(lines, footer, max_chars) + return compile_session_start_context(context, config) def _query_items(client, identity, categories, metadata_filter, limit): @@ -149,38 +114,6 @@ def _item_entry(item): } -def _section_entries(context, key): - if key == "recentRawData": - return context.get("recentRawData") or [] - return (context.get("items") or {}).get(key) or [] - - -def _render_entry(section_key, entry): - if section_key == "recentRawData": - return f"- [rawdata:{entry.get('id')}] {_clean(entry.get('caption'))}" - category = entry.get("category") or "memory" - return f"- [item:{entry.get('id')} {category}] {_clean(entry.get('text'))}" - - -def _truncate_lines(lines, footer, max_chars): - notice = "\n[truncated]\n" - budget = max(0, max_chars - len(notice) - len(footer)) - selected = [] - used = 0 - for line in lines: - addition = len(line) + (1 if selected else 0) - if used + addition > budget: - break - selected.append(line) - used += addition - if len(selected) <= 2: - base = "\n".join(lines[:2]) - available = max(0, budget - len(base)) - base = base[:available] - return f"{base}{notice}{footer}"[:max_chars] - return f"{chr(10).join(selected)}{notice}{footer}"[:max_chars] - - def _field(value, name): if isinstance(value, dict): return value.get(name) @@ -190,6 +123,3 @@ def _field(value, name): def _text(item): return _field(item, "text") - -def _clean(value): - return " ".join(str(value or "").split()) diff --git a/memind-integrations/codex/scripts/retrieve.py b/memind-integrations/codex/scripts/retrieve.py index ae0857b8..fc6f61dd 100644 --- a/memind-integrations/codex/scripts/retrieve.py +++ b/memind-integrations/codex/scripts/retrieve.py @@ -23,6 +23,7 @@ from lib.client import MemindClient from lib.agent_timeline import normalize_user_prompt_event from lib.config import load_config +from lib.context_compiler import compile_prompt_retrieval_context from lib.content import read_recent_context from lib.identity import resolve_identity from lib.logging_utils import debug_log @@ -30,71 +31,8 @@ from ingest import state_root -AGENT_CATEGORY_SECTIONS = [ - ("playbook", "## Agent Playbooks"), - ("resolution", "## Resolved Problems"), - ("tool", "## Tool Notes"), - ("directive", "## Directives"), -] - - def _format_context(data, config): - max_entries = int(config.get("retrieveMaxEntries", 8)) - max_chars = int(config.get("retrieveMaxChars", 6000)) - tier_rank = {"ROOT": 0, "BRANCH": 1, "LEAF": 2} - - insights = [insight for insight in (data.get("insights") or []) if insight.get("text")] - insights.sort(key=lambda insight: (tier_rank.get(str(insight.get("tier", "LEAF")).upper(), 2), str(insight.get("id", "")))) - high_level = [insight for insight in insights if str(insight.get("tier", "")).upper() in {"ROOT", "BRANCH"}] - selected_insights = (high_level or insights)[: min(3, max_entries)] - - remaining = max_entries - len(selected_insights) - items = [item for item in (data.get("items") or []) if item.get("text")] - items.sort( - key=lambda item: item.get("finalScore") if item.get("finalScore") is not None else item.get("vectorScore", 0), - reverse=True, - ) - selected_items = items[: max(0, remaining)] - - sections = [] - if selected_insights: - sections.append("## Insights") - sections.extend(f"- [insight:{insight.get('id')}] {insight.get('text')}" for insight in selected_insights) - grouped_agent_items = _group_agent_items(selected_items) - for category, header in AGENT_CATEGORY_SECTIONS: - category_items = grouped_agent_items.get(category, []) - if category_items: - if sections: - sections.append("") - sections.append(header) - sections.extend(f"- [item:{item.get('id')}] {item.get('text')}" for item in category_items) - general_items = [item for item in selected_items if _item_category(item) not in grouped_agent_items] - if general_items: - if sections: - sections.append("") - sections.append("## Memory Items") - sections.extend(f"- [item:{item.get('id')}] {item.get('text')}" for item in general_items) - degraded_notice = "" - if data.get("status") == "degraded": - degraded_notice = "\n[Note: Memory retrieval encountered an error. Results may be incomplete.]\n" - if not sections and not degraded_notice: - return "" - body = "\n".join(sections)[:max_chars] - return f"\n{config.get('retrievePromptPreamble') or ''}\n{body}{degraded_notice}\n" - - -def _item_category(item): - return str(item.get("category") or "").strip().lower() - - -def _group_agent_items(items): - agent_categories = {category for category, _header in AGENT_CATEGORY_SECTIONS} - grouped = {} - for item in items: - category = _item_category(item) - if category in agent_categories: - grouped.setdefault(category, []).append(item) - return grouped + return compile_prompt_retrieval_context(data, config) def main(): diff --git a/memind-integrations/codex/tests/test_hooks.py b/memind-integrations/codex/tests/test_hooks.py index d743544e..668fb3d4 100644 --- a/memind-integrations/codex/tests/test_hooks.py +++ b/memind-integrations/codex/tests/test_hooks.py @@ -60,9 +60,9 @@ def test_format_context_prioritizes_tiers_and_scores(self): context = _format_context(data, {"retrieveMaxEntries": 4, "retrieveMaxChars": 1000, "retrievePromptPreamble": "P"}) self.assertIn("## Insights", context) self.assertIn("## Memory Items", context) - self.assertLess(context.index("[insight:1] root"), context.index("[insight:2] branch")) + self.assertLess(context.index("[insight:1 root] root"), context.index("[insight:2 branch] branch")) self.assertNotIn("leaf", context) - self.assertLess(context.index("[item:11] high"), context.index("[item:10] low")) + self.assertLess(context.index("[item:11 memory] high"), context.index("[item:10 memory] low")) def test_format_context_includes_degraded_notice_without_results(self): sys.path.insert(0, str(ROOT / "scripts")) @@ -115,6 +115,8 @@ def test_format_context_groups_agent_memory_categories(self): self.assertIn("## Resolved Problems", context) self.assertIn("## Tool Notes", context) self.assertIn("## Directives", context) + self.assertLess(context.index("## Directives"), context.index("## Resolved Problems")) + self.assertLess(context.index("## Resolved Problems"), context.index("## Agent Playbooks")) self.assertNotIn("## Memory Items", context) def test_retrieve_fail_open_when_memind_unavailable(self): diff --git a/memind-integrations/codex/tests/test_session_context.py b/memind-integrations/codex/tests/test_session_context.py index 74015d4e..0344b842 100644 --- a/memind-integrations/codex/tests/test_session_context.py +++ b/memind-integrations/codex/tests/test_session_context.py @@ -32,7 +32,12 @@ def query_raw_data(self, user_id, agent_id, **kwargs): self.raw_data_calls.append((user_id, agent_id, kwargs)) return types.SimpleNamespace( raw_data=[ - types.SimpleNamespace(id="rd-1", caption="Fixed Codex agent timeline flushing.", metadata={}), + types.SimpleNamespace( + id="rd-1", + caption="Fixed Codex agent timeline flushing.", + metadata={}, + created_at="2026-05-27T01:00:00Z", + ), ] ) @@ -76,8 +81,11 @@ def test_build_session_context_renders_codex_project_context(self): rendered = render_session_context(context, {"sessionContextMaxChars": 4000}) self.assertIn('', rendered) + self.assertIn("Historical Memind project memory", rendered) + self.assertIn("Current user instructions and repository files take precedence", rendered) + self.assertIn("Verify old implementation details against the working tree", rendered) self.assertIn("## Continue From", rendered) - self.assertIn("[rawdata:rd-1] Fixed Codex agent timeline flushing.", rendered) + self.assertIn("[rawdata:rd-1, 2026-05-27] Fixed Codex agent timeline flushing.", rendered) self.assertIn("## Must Follow", rendered) self.assertIn("[item:it-1 directive] Keep Codex and Claude Code behavior aligned.", rendered) self.assertIn("## Watch Outs", rendered) From 4071b6f6f5b85a80d3560cb086e1b0e8322decaa Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 10:55:59 +0800 Subject: [PATCH 35/54] fix: install codex context compiler --- memind-integrations/codex/install.sh | 1 + memind-integrations/codex/tests/test_installer.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/memind-integrations/codex/install.sh b/memind-integrations/codex/install.sh index 0f767e10..734d9c53 100644 --- a/memind-integrations/codex/install.sh +++ b/memind-integrations/codex/install.sh @@ -188,6 +188,7 @@ download_remote_install() { "scripts/lib/agent_timeline.py" "scripts/lib/client.py" "scripts/lib/config.py" + "scripts/lib/context_compiler.py" "scripts/lib/content.py" "scripts/lib/identity.py" "scripts/lib/logging_utils.py" diff --git a/memind-integrations/codex/tests/test_installer.py b/memind-integrations/codex/tests/test_installer.py index 42a3bc7e..f6b8b57a 100644 --- a/memind-integrations/codex/tests/test_installer.py +++ b/memind-integrations/codex/tests/test_installer.py @@ -78,6 +78,11 @@ def _valid_memind_package(root): class InstallerTest(unittest.TestCase): + def test_remote_install_file_list_includes_context_compiler(self): + install_script = (ROOT / "install.sh").read_text() + + self.assertIn('"scripts/lib/context_compiler.py"', install_script) + def test_install_merges_and_reinstall_is_idempotent(self): with tempfile.TemporaryDirectory() as tmp: hooks_path = Path(tmp) / "hooks.json" From d8e2ec2110ac3842af70523aea414d6510ea297f Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 10:57:54 +0800 Subject: [PATCH 36/54] docs: explain agent context compiler output --- memind-integrations/claude-code/README.md | 43 +++++++++++++++-------- memind-integrations/codex/README.md | 43 +++++++++++++++-------- 2 files changed, 58 insertions(+), 28 deletions(-) diff --git a/memind-integrations/claude-code/README.md b/memind-integrations/claude-code/README.md index d4bdc9d5..36bc17d9 100644 --- a/memind-integrations/claude-code/README.md +++ b/memind-integrations/claude-code/README.md @@ -208,22 +208,22 @@ The injected context is compiled from generic OpenAPI query results: ```text -Memind project memory. Use only when directly helpful. Prefer explicit user instructions and repository files over memory if they conflict. +Historical Memind project memory. Use only when directly helpful. Current user instructions and repository files take precedence. Verify old implementation details against the working tree before relying on them. ## Continue From -- [rawdata:rd-1] Previous turn summary from agent_timeline caption. +- [rawdata:rd-1, 2026-05-27] Completed SessionStart context injection for Claude Code and Codex. ## Must Follow -- [item:101 directive] Project or agent instruction extracted from previous work. +- [item:101 directive, 2026-05-27] Keep userId and agentId stable; use metadata.projectSlug for project isolation. ## Watch Outs -- [item:102 resolution] Previously solved issue or failure pattern. +- [item:102 resolution, 2026-05-27] Codex tests must run with Python 3.12; older Python can fail on modern type syntax. ## Reusable Playbooks -- [item:103 playbook] Repeatable workflow for this project. +- [item:103 playbook, 2026-05-27] After changing Claude Code or Codex hooks, run both integration unittest suites and git diff --check. ## Useful Facts -- [item:104 event] Project fact useful for continuing work. +- [item:104 event, 2026-05-27] SessionStart is read-only: it queries memory and injects context without writing rawdata. ``` @@ -239,16 +239,31 @@ The injected context format is: ```text Relevant memories from Memind. Use only when directly helpful: + +## Directives +- [item:201 directive] Do not default Claude Code or Codex to conversation rawdata. + +## Resolved Problems +- [item:202 resolution] Retry spool events are cleared only after successful agent_timeline extraction. + +## Agent Playbooks +- [item:203 playbook] When hooks change, run both integration test suites and git diff --check. + +## Tool Notes +- [item:204 tool] Use Python 3.12 for the Codex integration test suite. + ## Insights -- [insight:42] ... +- [insight:301 root] Coding-agent integrations share memory through stable userId and agentId. ## Memory Items -- [item:101] ... +- [item:205 event] rawdata-agent emits agent_episode segment metadata. ``` -Insights are formatted before memory items. Higher-level insights (`ROOT`, then `BRANCH`) are preferred; `LEAF` -insights are omitted by default unless no higher-level insights are available. +The adapter compiles retrieved Memind results into execution-oriented sections. Directives, resolved problems, +playbooks, and tool notes get independent caps so a high-scoring generic item cannot crowd out coding-agent memory. +Higher-level insights (`ROOT`, then `BRANCH`) are preferred; `LEAF` insights are omitted by default unless no +higher-level insights are available. `retrieveContextTurns` defaults to `0`, so retrieval uses only the current prompt and does not read large transcripts. Set it to `1` or `2` if your prompts are often short, such as "fix this" or "continue". @@ -256,14 +271,14 @@ transcripts. Set it to `1` or `2` if your prompts are often short, such as "fix Agent memory items are grouped separately when returned by Memind: ```text -## Agent Playbooks +## Directives ## Resolved Problems +## Agent Playbooks ## Tool Notes -## Directives ``` -This phase keeps retrieval formatting intentionally simple. It does not add a new Retrieval Context Compiler; -retrieved memories are still formatted from Memind items and insights by the adapter. +The compiler deduplicates per section, applies section budgets, and preserves the closing XML-style wrapper when the +context must be truncated. ## Ingestion Behavior diff --git a/memind-integrations/codex/README.md b/memind-integrations/codex/README.md index e284b46a..2ad90e46 100644 --- a/memind-integrations/codex/README.md +++ b/memind-integrations/codex/README.md @@ -223,22 +223,22 @@ The injected context is compiled from generic OpenAPI query results: ```text -Memind project memory. Use only when directly helpful. Prefer explicit user instructions and repository files over memory if they conflict. +Historical Memind project memory. Use only when directly helpful. Current user instructions and repository files take precedence. Verify old implementation details against the working tree before relying on them. ## Continue From -- [rawdata:rd-1] Previous turn summary from agent_timeline caption. +- [rawdata:rd-1, 2026-05-27] Completed SessionStart context injection for Claude Code and Codex. ## Must Follow -- [item:101 directive] Project or agent instruction extracted from previous work. +- [item:101 directive, 2026-05-27] Keep userId and agentId stable; use metadata.projectSlug for project isolation. ## Watch Outs -- [item:102 resolution] Previously solved issue or failure pattern. +- [item:102 resolution, 2026-05-27] Codex tests must run with Python 3.12; older Python can fail on modern type syntax. ## Reusable Playbooks -- [item:103 playbook] Repeatable workflow for this project. +- [item:103 playbook, 2026-05-27] After changing Claude Code or Codex hooks, run both integration unittest suites and git diff --check. ## Useful Facts -- [item:104 event] Project fact useful for continuing work. +- [item:104 event, 2026-05-27] SessionStart is read-only: it queries memory and injects context without writing rawdata. ``` @@ -254,16 +254,31 @@ The injected context format is: ```text Relevant memories from Memind. Use only when directly helpful: + +## Directives +- [item:201 directive] Do not default Claude Code or Codex to conversation rawdata. + +## Resolved Problems +- [item:202 resolution] Retry spool events are cleared only after successful agent_timeline extraction. + +## Agent Playbooks +- [item:203 playbook] When hooks change, run both integration test suites and git diff --check. + +## Tool Notes +- [item:204 tool] Use Python 3.12 for the Codex integration test suite. + ## Insights -- [insight:42] ... +- [insight:301 root] Coding-agent integrations share memory through stable userId and agentId. ## Memory Items -- [item:101] ... +- [item:205 event] rawdata-agent emits agent_episode segment metadata. ``` -Insights are formatted before memory items. Higher-level insights (`ROOT`, then `BRANCH`) are preferred; `LEAF` -insights are omitted by default unless no higher-level insights are available. +The adapter compiles retrieved Memind results into execution-oriented sections. Directives, resolved problems, +playbooks, and tool notes get independent caps so a high-scoring generic item cannot crowd out coding-agent memory. +Higher-level insights (`ROOT`, then `BRANCH`) are preferred; `LEAF` insights are omitted by default unless no +higher-level insights are available. `retrieveContextTurns` defaults to `0`, so retrieval uses only the current prompt and does not read large transcripts. Set it to `1` or `2` if your prompts are often short, such as "fix this" or "continue". @@ -271,14 +286,14 @@ transcripts. Set it to `1` or `2` if your prompts are often short, such as "fix Agent memory items are grouped separately when returned by Memind: ```text -## Agent Playbooks +## Directives ## Resolved Problems +## Agent Playbooks ## Tool Notes -## Directives ``` -This phase keeps retrieval formatting intentionally simple. It does not add a new Retrieval Context Compiler; -retrieved memories are still formatted from Memind items and insights by the adapter. +The compiler deduplicates per section, applies section budgets, and preserves the closing XML-style wrapper when the +context must be truncated. ## Ingestion Behavior From 2a64cf148956f297319e0cac49d4a96448d346bb Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 11:01:36 +0800 Subject: [PATCH 37/54] docs: add agent context compiler implementation plan --- .../2026-05-27-agent-context-compiler.md | 1406 +++++++++++++++++ 1 file changed, 1406 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-27-agent-context-compiler.md diff --git a/docs/superpowers/plans/2026-05-27-agent-context-compiler.md b/docs/superpowers/plans/2026-05-27-agent-context-compiler.md new file mode 100644 index 00000000..1b90336d --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-agent-context-compiler.md @@ -0,0 +1,1406 @@ +# Agent Context Compiler Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a deterministic Agent Context Compiler for Claude Code and Codex so Memind memory is injected as high-value coding-agent context instead of lightly formatted query output. + +**Architecture:** Keep Memind core, OpenAPI, and `rawdata-agent` unchanged. Implement the compiler in the Claude Code and Codex integration layers: normalize retrieved rawdata/items/insights into common entries, classify them into agent-oriented sections, rank and deduplicate them, then render bounded context for `SessionStart` and `UserPromptSubmit`. `rawdata-agent` remains responsible for ingestion and extraction; integrations remain responsible for prompt injection. + +**Tech Stack:** Python 3.10+ hook scripts and `unittest`, Memind Python client query/retrieve APIs, Claude Code hooks, Codex hooks, existing integration settings and installers. + +--- + +## Design Summary + +The compiler has two modes: + +- `session_start`: project-continuity context injected on `SessionStart`. +- `prompt_retrieval`: query-aware context injected on `UserPromptSubmit`. + +Both modes use the same deterministic utilities: + +- Normalize Memind rawdata/items/insights into `ContextEntry` dictionaries. +- Classify entries into stable sections. +- Rank entries by section-specific usefulness. +- Deduplicate repeated memory text within each section. Preserve `agent_timeline` captions in `Continue From` because they are turn summaries; do not let caption text remove structured `directive`, `resolution`, or `playbook` items. Allow lower-priority `facts` and generic memory items to be removed when they duplicate higher-value structured items. +- Render with section budgets, per-entry caps, stable source citations, and an explicit historical-memory preamble. + +This plan intentionally avoids core/OpenAPI changes. It uses existing calls: + +- `query_raw_data(types=["agent_timeline"], metadata_filter=project_filter, include={"metadata": True, "segment": False})` +- `query_items(categories=[...], raw_data_types=["agent_timeline"], metadata_filter=project_filter)` +- `memory.retrieve(query=...)` + +## File Structure + +Create: + +- `memind-integrations/claude-code/scripts/lib/context_compiler.py` + - Owns normalization, classification, ranking, dedupe, budgets, and rendering for Claude Code. +- `memind-integrations/codex/scripts/lib/context_compiler.py` + - Same implementation for Codex so the installed adapter is self-contained. +- `memind-integrations/claude-code/tests/test_context_compiler.py` + - Unit tests for compiler behavior independent of hook subprocess tests. +- `memind-integrations/codex/tests/test_context_compiler.py` + - Same coverage for Codex. + +Modify: + +- `memind-integrations/claude-code/scripts/lib/session_context.py` + - Keep fetching project memory, delegate rendering to `context_compiler`. +- `memind-integrations/codex/scripts/lib/session_context.py` + - Same as Claude Code. +- `memind-integrations/claude-code/scripts/retrieve.py` + - Replace local `_format_context` with `compile_prompt_retrieval_context`. +- `memind-integrations/codex/scripts/retrieve.py` + - Same as Claude Code. +- `memind-integrations/claude-code/tests/test_session_context.py` + - Assert fetch behavior remains unchanged and new rendering behavior appears. +- `memind-integrations/codex/tests/test_session_context.py` + - Same as Claude Code. +- `memind-integrations/claude-code/tests/test_hooks.py` + - Update `_format_context` tests to target compiler through `retrieve.py` or move assertions to `test_context_compiler.py`. +- `memind-integrations/codex/tests/test_hooks.py` + - Same as Claude Code. +- `memind-integrations/codex/install.sh` + - Add `scripts/lib/context_compiler.py` to remote install file list. +- `memind-integrations/codex/tests/test_installer.py` + - Assert `context_compiler.py` is included in installer file checks. +- `memind-integrations/claude-code/README.md` + - Update SessionStart and retrieval examples. +- `memind-integrations/codex/README.md` + - Same as Claude Code. + +Do not modify: + +- `memind-core` +- `memind-server` +- `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent` +- OpenAPI schema or generated models + +--- + +## Task 1: Add Shared Compiler Behavior to Claude Code + +**Files:** + +- Create: `memind-integrations/claude-code/scripts/lib/context_compiler.py` +- Create: `memind-integrations/claude-code/tests/test_context_compiler.py` + +- [ ] **Step 1: Write failing tests for session-start compilation** + +Create `memind-integrations/claude-code/tests/test_context_compiler.py` with this starting content: + +```python +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +from scripts.lib.context_compiler import compile_session_start_context + + +class ContextCompilerTest(unittest.TestCase): + def test_session_start_context_ranks_dedupes_and_preserves_priority_sections(self): + context = { + "projectSlug": "memind-main", + "recentRawData": [ + { + "id": "rd-old", + "caption": "Older work on unrelated install docs.", + "createdAt": "2026-05-25T10:00:00Z", + "metadata": {}, + }, + { + "id": "rd-new", + "caption": "Completed SessionStart context injection for Claude Code and Codex; keep userId and agentId stable.", + "createdAt": "2026-05-27T10:00:00Z", + "metadata": {}, + }, + ], + "items": { + "directive": [ + { + "id": "dir-1", + "category": "directive", + "text": "Keep userId and agentId stable; use metadata.projectSlug for project isolation.", + "createdAt": "2026-05-27T09:00:00Z", + "metadata": {}, + } + ], + "watchOut": [ + { + "id": "res-1", + "category": "resolution", + "text": "Codex tests must run with Python 3.12; older Python can fail on modern type syntax.", + "createdAt": "2026-05-27T08:00:00Z", + "metadata": {}, + }, + { + "id": "res-dup", + "category": "resolution", + "text": "codex tests must run with python 3.12 older python can fail on modern type syntax", + "createdAt": "2026-05-26T08:00:00Z", + "metadata": {}, + }, + ], + "playbook": [ + { + "id": "pb-1", + "category": "playbook", + "text": "After changing Claude Code or Codex hooks, run both integration unittest suites and git diff --check.", + "createdAt": "2026-05-27T07:00:00Z", + "metadata": {}, + } + ], + "fact": [ + { + "id": "fact-1", + "category": "event", + "text": "SessionStart is read-only: it queries memory and injects context without writing rawdata.", + "createdAt": "2026-05-27T06:00:00Z", + "metadata": {}, + } + ], + }, + } + + rendered = compile_session_start_context(context, {"sessionContextMaxChars": 6000}) + + self.assertIn('', rendered) + self.assertIn("Historical Memind project memory", rendered) + self.assertIn("Current user instructions and repository files take precedence", rendered) + self.assertIn("Verify old implementation details against the working tree", rendered) + self.assertIn("## Continue From", rendered) + self.assertIn("[rawdata:rd-new, 2026-05-27] Completed SessionStart context injection", rendered) + self.assertLess(rendered.index("rd-new"), rendered.index("rd-old")) + self.assertIn("## Must Follow", rendered) + self.assertIn("[item:dir-1 directive, 2026-05-27] Keep userId and agentId stable", rendered) + self.assertIn("rd-new", rendered, "turn-summary caption should stay in Continue From") + self.assertIn("dir-1", rendered, "structured item should not be removed by caption text") + self.assertIn("## Watch Outs", rendered) + self.assertIn("[item:res-1 resolution, 2026-05-27] Codex tests must run", rendered) + self.assertNotIn("res-dup", rendered) + self.assertIn("## Reusable Playbooks", rendered) + self.assertIn("[item:pb-1 playbook, 2026-05-27] After changing Claude Code", rendered) + self.assertIn("## Useful Facts", rendered) + self.assertIn("[item:fact-1 event, 2026-05-27] SessionStart is read-only", rendered) + self.assertTrue(rendered.endswith("")) + + def test_session_start_context_uses_section_budget_instead_of_naive_line_truncation(self): + context = { + "projectSlug": "memind-main", + "recentRawData": [ + {"id": "rd-1", "caption": "Recent work " + "A" * 500, "createdAt": "2026-05-27T01:00:00Z"}, + ], + "items": { + "directive": [ + {"id": "dir-1", "category": "directive", "text": "Do not break stable identity.", "createdAt": "2026-05-27T01:00:00Z"} + ], + "watchOut": [ + {"id": "res-1", "category": "resolution", "text": "Always avoid committing __pycache__ files.", "createdAt": "2026-05-27T01:00:00Z"} + ], + "playbook": [ + {"id": "pb-1", "category": "playbook", "text": "Run focused integration tests after hook changes.", "createdAt": "2026-05-27T01:00:00Z"} + ], + "fact": [ + {"id": "fact-1", "category": "event", "text": "Fact " + "F" * 400, "createdAt": "2026-05-27T01:00:00Z"} + ], + }, + } + + rendered = compile_session_start_context(context, {"sessionContextMaxChars": 900}) + + self.assertLessEqual(len(rendered), 900) + self.assertIn("## Must Follow", rendered) + self.assertIn("Do not break stable identity.", rendered) + self.assertIn("## Watch Outs", rendered) + self.assertIn("Always avoid committing __pycache__", rendered) + self.assertIn("truncated: lower-priority memories omitted", rendered) + self.assertTrue(rendered.endswith("")) + + def test_session_start_context_returns_empty_when_no_entries_exist(self): + rendered = compile_session_start_context( + {"projectSlug": "memind-main", "recentRawData": [], "items": {}}, + {"sessionContextMaxChars": 6000}, + ) + + self.assertEqual(rendered, "") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run the failing compiler tests** + +Run: + +```bash +python3 -m unittest tests/test_context_compiler.py -v +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'scripts.lib.context_compiler'`. + +- [ ] **Step 3: Implement Claude Code context compiler** + +Create `memind-integrations/claude-code/scripts/lib/context_compiler.py`: + +```python +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import html +import re +import string +from datetime import datetime + +DEFAULT_MAX_CHARS = 6000 +DEFAULT_SESSION_ENTRY_MAX_CHARS = 520 +DEFAULT_RETRIEVAL_ENTRY_MAX_CHARS = 700 + +SESSION_SECTION_ORDER = [ + ("continueFrom", "## Continue From"), + ("mustFollow", "## Must Follow"), + ("watchOuts", "## Watch Outs"), + ("playbooks", "## Reusable Playbooks"), + ("facts", "## Useful Facts"), +] + +SESSION_SECTION_BUDGETS = { + "continueFrom": 1300, + "mustFollow": 1300, + "watchOuts": 1400, + "playbooks": 1200, + "facts": 800, +} + +PROMPT_SECTION_ORDER = [ + ("directives", "## Directives"), + ("resolvedProblems", "## Resolved Problems"), + ("playbooks", "## Agent Playbooks"), + ("toolNotes", "## Tool Notes"), + ("insights", "## Insights"), + ("memoryItems", "## Memory Items"), +] + +PROMPT_SECTION_BUDGETS = { + "directives": 900, + "resolvedProblems": 1600, + "playbooks": 1200, + "toolNotes": 900, + "insights": 800, + "memoryItems": 600, +} + +PROMPT_SECTION_LIMITS = { + "directives": 3, + "resolvedProblems": 5, + "playbooks": 4, + "toolNotes": 3, + "insights": 3, + "memoryItems": 3, +} + +WATCH_OUT_TERMS = { + "error", + "failed", + "failure", + "fix", + "fixed", + "regression", + "test", + "timeout", + "retry", + "avoid", +} + +PLAYBOOK_TERMS = {"run", "after", "before", "when", "then", "workflow", "steps", "verify"} + + +def compile_session_start_context(context, config): + sections = { + "continueFrom": _normalize_rawdata(context.get("recentRawData") or []), + "mustFollow": _normalize_items(((context.get("items") or {}).get("directive") or []), "mustFollow"), + "watchOuts": _normalize_items(((context.get("items") or {}).get("watchOut") or []), "watchOuts"), + "playbooks": _normalize_items(((context.get("items") or {}).get("playbook") or []), "playbooks"), + "facts": _normalize_items(((context.get("items") or {}).get("fact") or []), "facts"), + } + project_slug = context.get("projectSlug") or "unknown" + max_chars = int(config.get("sessionContextMaxChars", DEFAULT_MAX_CHARS)) + entry_max_chars = int(config.get("sessionContextEntryMaxChars", DEFAULT_SESSION_ENTRY_MAX_CHARS)) + return _render_context( + wrapper="memind_session_context", + attrs={"project": project_slug}, + preamble=( + "Historical Memind project memory. Use only when directly helpful. " + "Current user instructions and repository files take precedence. " + "Verify old implementation details against the working tree before relying on them." + ), + sections=_prepare_sections(sections, "session_start"), + order=SESSION_SECTION_ORDER, + budgets=SESSION_SECTION_BUDGETS, + max_chars=max_chars, + entry_max_chars=entry_max_chars, + ) + + +def compile_prompt_retrieval_context(data, config): + max_entries = int(config.get("retrieveMaxEntries", 8)) + max_chars = int(config.get("retrieveMaxChars", DEFAULT_MAX_CHARS)) + entry_max_chars = int(config.get("retrieveEntryMaxChars", DEFAULT_RETRIEVAL_ENTRY_MAX_CHARS)) + + items = [_normalize_retrieved_item(item) for item in data.get("items") or [] if _field(item, "text")] + insights = [_normalize_insight(insight) for insight in data.get("insights") or [] if _field(insight, "text")] + sorted_items = _sort_prompt_items(items) + + sections = { + "directives": _top_category(sorted_items, "directive", _section_limit("directives", max_entries)), + "resolvedProblems": _top_category(sorted_items, "resolution", _section_limit("resolvedProblems", max_entries)), + "playbooks": _top_category(sorted_items, "playbook", _section_limit("playbooks", max_entries)), + "toolNotes": _top_category(sorted_items, "tool", _section_limit("toolNotes", max_entries)), + "insights": _sort_insights(insights)[: _section_limit("insights", max_entries)], + "memoryItems": _top_general_items(sorted_items, _section_limit("memoryItems", max_entries)), + } + + degraded_notice = "" + if data.get("status") == "degraded": + degraded_notice = "[Note: Memory retrieval encountered an error. Results may be incomplete.]" + + preamble = config.get("retrievePromptPreamble") or ( + "Relevant Memind memories for the current request. Use only when directly helpful." + ) + rendered = _render_context( + wrapper="memind_memories", + attrs={}, + preamble=preamble, + sections=_prepare_sections(sections, "prompt_retrieval"), + order=PROMPT_SECTION_ORDER, + budgets=PROMPT_SECTION_BUDGETS, + max_chars=max_chars, + entry_max_chars=entry_max_chars, + trailing_notice=degraded_notice, + ) + if rendered or not degraded_notice: + return rendered + return _render_context( + wrapper="memind_memories", + attrs={}, + preamble=preamble, + sections={}, + order=PROMPT_SECTION_ORDER, + budgets=PROMPT_SECTION_BUDGETS, + max_chars=max_chars, + entry_max_chars=entry_max_chars, + trailing_notice=degraded_notice, + allow_notice_only=True, + ) + + +def _prepare_sections(sections, mode): + prepared = {} + high_value_seen = set() + for key, entries in sections.items(): + ranked = _rank_entries(entries, key, mode) + deduped = [] + section_seen = set() + for entry in ranked: + dedupe_key = _dedupe_key(entry["text"]) + if not dedupe_key or dedupe_key in section_seen: + continue + if key in {"facts", "memoryItems"} and dedupe_key in high_value_seen: + continue + section_seen.add(dedupe_key) + deduped.append(entry) + if key not in {"continueFrom", "facts", "memoryItems"}: + high_value_seen.update(_dedupe_key(entry["text"]) for entry in deduped if _dedupe_key(entry["text"])) + if deduped: + prepared[key] = deduped + return prepared + + +def _normalize_rawdata(raw_data): + entries = [] + for raw in raw_data: + text = _field(raw, "caption") + if not text: + continue + entries.append( + { + "kind": "rawdata", + "id": _field(raw, "id"), + "category": "agent_timeline", + "text": _clean(text), + "createdAt": _field(raw, "createdAt") or _field(raw, "created_at"), + "score": 0, + } + ) + return entries + + +def _normalize_items(items, section): + entries = [] + for item in items: + text = _field(item, "text") + if not text: + continue + category = str(_field(item, "category") or "memory").strip().lower() + entries.append( + { + "kind": "item", + "id": _field(item, "id"), + "category": category, + "text": _clean(text), + "createdAt": _field(item, "createdAt") or _field(item, "created_at"), + "score": _section_score(section, text), + } + ) + return entries + + +def _normalize_retrieved_item(item): + category = str(_field(item, "category") or "memory").strip().lower() + return { + "kind": "item", + "id": _field(item, "id"), + "category": category, + "text": _clean(_field(item, "text")), + "createdAt": _field(item, "createdAt") or _field(item, "created_at"), + "score": _number(_field(item, "finalScore"), _field(item, "vectorScore"), 0), + } + + +def _normalize_insight(insight): + return { + "kind": "insight", + "id": _field(insight, "id"), + "category": str(_field(insight, "tier") or "insight").strip().lower(), + "text": _clean(_field(insight, "text")), + "createdAt": _field(insight, "createdAt") or _field(insight, "created_at"), + "score": 0, + } + + +def _rank_entries(entries, section, mode): + if section == "continueFrom": + return sorted(entries, key=lambda entry: _timestamp(entry.get("createdAt")), reverse=True)[:3] + return sorted( + entries, + key=lambda entry: ( + entry.get("score", 0), + _timestamp(entry.get("createdAt")), + -len(entry.get("text", "")), + ), + reverse=True, + ) + + +def _sort_prompt_items(items): + return sorted( + items, + key=lambda entry: ( + entry.get("score", 0), + _timestamp(entry.get("createdAt")), + _category_priority(entry.get("category")), + ), + reverse=True, + ) + + +def _sort_insights(insights): + tier_rank = {"root": 3, "branch": 2, "leaf": 1} + return sorted( + insights, + key=lambda entry: ( + tier_rank.get(entry.get("category", ""), 0), + _timestamp(entry.get("createdAt")), + str(entry.get("id") or ""), + ), + reverse=True, + ) + + +def _top_category(items, category, limit): + return [entry for entry in items if entry["category"] == category][:limit] + + +def _top_general_items(items, limit): + agent_categories = {"directive", "resolution", "playbook", "tool"} + return [entry for entry in items if entry["category"] not in agent_categories][:limit] + + +def _section_limit(section, max_entries): + return max(0, min(PROMPT_SECTION_LIMITS.get(section, max_entries), max_entries)) + + +def _category_priority(category): + return {"directive": 5, "resolution": 4, "playbook": 3, "tool": 2}.get(category or "", 1) + + +def _section_score(section, text): + lowered = str(text or "").lower() + if section == "watchOuts": + return sum(1 for term in WATCH_OUT_TERMS if term in lowered) + if section == "playbooks": + return sum(1 for term in PLAYBOOK_TERMS if term in lowered) + if section == "mustFollow": + return 2 if len(lowered) <= 220 else 1 + return 0 + + +def _render_context( + wrapper, + attrs, + preamble, + sections, + order, + budgets, + max_chars, + entry_max_chars, + trailing_notice="", + allow_notice_only=False, +): + if not sections and not trailing_notice and not allow_notice_only: + return "" + + open_tag = _open_tag(wrapper, attrs) + close_tag = f"" + fixed_lines = [open_tag, preamble] + rendered_sections = [] + truncated = False + + for key, title in order: + entries = sections.get(key) or [] + if not entries: + continue + budget = budgets.get(key, 800) + lines, section_truncated = _render_section(title, entries, budget, entry_max_chars) + truncated = truncated or section_truncated + if lines: + rendered_sections.append(lines) + + if trailing_notice: + rendered_sections.append([trailing_notice]) + + if not rendered_sections and not allow_notice_only: + return "" + + lines = list(fixed_lines) + for section_lines in rendered_sections: + lines.append("") + lines.extend(section_lines) + if truncated: + lines.append("") + lines.append("[truncated: lower-priority memories omitted]") + lines.append(close_tag) + + rendered = "\n".join(lines) + if len(rendered) <= max_chars: + return rendered + return _fit_to_total_budget(lines, close_tag, max_chars) + + +def _render_section(title, entries, budget, entry_max_chars): + lines = [title] + used = len(title) + truncated = False + for entry in entries: + line = _render_entry(entry, entry_max_chars) + addition = len(line) + 1 + if used + addition > budget: + truncated = True + break + lines.append(line) + used += addition + return (lines if len(lines) > 1 else []), truncated + + +def _render_entry(entry, max_chars): + date = _date_label(entry.get("createdAt")) + if entry["kind"] == "rawdata": + label = f"rawdata:{entry.get('id')}" + elif entry["kind"] == "insight": + label = f"insight:{entry.get('id')} {entry.get('category') or 'insight'}" + else: + label = f"item:{entry.get('id')} {entry.get('category') or 'memory'}" + if date: + label = f"{label}, {date}" + return f"- [{label}] {_clip(entry.get('text'), max_chars)}" + + +def _fit_to_total_budget(lines, close_tag, max_chars): + notice = "[truncated: lower-priority memories omitted]" + full_suffix = f"\n{notice}\n{close_tag}" + suffix = full_suffix if len(full_suffix) < max_chars else f"\n{close_tag}" + selected = [] + budget = max(0, max_chars - len(suffix)) + used = 0 + for line in lines: + if line == close_tag or line == notice: + continue + addition = len(line) + (1 if selected else 0) + if used + addition > budget: + break + selected.append(line) + used += addition + prefix = "\n".join(selected) + if len(prefix) > budget: + prefix = prefix[:budget].rstrip() + result = f"{prefix}{suffix}" if prefix else suffix.lstrip() + if len(result) <= max_chars: + return result + overflow = len(result) - max_chars + prefix = prefix[:-overflow].rstrip() if overflow < len(prefix) else "" + return f"{prefix}{suffix}" if prefix else suffix.lstrip() + + +def _open_tag(wrapper, attrs): + if not attrs: + return f"<{wrapper}>" + rendered = " ".join( + f'{name}="{html.escape(str(value), quote=True)}"' for name, value in attrs.items() + ) + return f"<{wrapper} {rendered}>" + + +def _field(value, name): + if isinstance(value, dict): + return value.get(name) + return getattr(value, name, None) + + +def _clean(value): + return " ".join(str(value or "").split()) + + +def _clip(value, max_chars): + cleaned = _clean(value) + if len(cleaned) <= max_chars: + return cleaned + return cleaned[: max(0, max_chars - 12)].rstrip() + " [truncated]" + + +def _dedupe_key(value): + cleaned = _clean(value).lower() + cleaned = cleaned.translate(str.maketrans("", "", string.punctuation)) + cleaned = re.sub(r"\s+", " ", cleaned).strip() + return cleaned[:260] + + +def _timestamp(value): + if not value: + return 0 + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp() + except ValueError: + return 0 + + +def _date_label(value): + if not value: + return "" + text = str(value) + return text[:10] if len(text) >= 10 else text + + +def _number(*values): + for value in values: + if value is None: + continue + try: + return float(value) + except (TypeError, ValueError): + continue + return 0 +``` + +- [ ] **Step 4: Run Claude Code compiler tests** + +Run: + +```bash +python3 -m unittest tests/test_context_compiler.py -v +``` + +Expected: PASS. + +- [ ] **Step 5: Commit Task 1** + +Run: + +```bash +git add memind-integrations/claude-code/scripts/lib/context_compiler.py memind-integrations/claude-code/tests/test_context_compiler.py +git commit -m "feat: add claude code agent context compiler" +``` + +Expected: commit succeeds. + +--- + +## Task 2: Wire Claude Code SessionStart to the Compiler + +**Files:** + +- Modify: `memind-integrations/claude-code/scripts/lib/session_context.py` +- Modify: `memind-integrations/claude-code/tests/test_session_context.py` + +- [ ] **Step 1: Update failing SessionStart expectations** + +Modify `memind-integrations/claude-code/tests/test_session_context.py`: + +```python +from scripts.lib.session_context import build_session_context, render_session_context +``` + +Keep the import as-is, then update `test_build_session_context_queries_current_project_memory` assertions to include the improved preamble and citations: + +```python +self.assertIn("Historical Memind project memory", rendered) +self.assertIn("Current user instructions and repository files take precedence", rendered) +self.assertIn("Verify old implementation details against the working tree", rendered) +self.assertIn("[rawdata:rd-1, 2026-05-27] Implemented generic memory query APIs", rendered) +self.assertIn("[item:it-1 directive] Keep userId and agentId stable.", rendered) +``` + +Keep the existing API call assertions unchanged. + +- [ ] **Step 2: Run SessionStart tests to verify failure** + +Run: + +```bash +python3 -m unittest tests/test_session_context.py -v +``` + +Expected: FAIL because `render_session_context` still emits the old preamble and rawdata citations without dates. + +- [ ] **Step 3: Delegate rendering from session_context to context_compiler** + +Modify `memind-integrations/claude-code/scripts/lib/session_context.py`. + +Add import near the top: + +```python +from lib.context_compiler import compile_session_start_context +``` + +Replace the body of `render_session_context` with: + +```python +def render_session_context(context, config): + return compile_session_start_context(context, config) +``` + +Delete these old rendering-only symbols from `session_context.py` after `render_session_context` delegates to the compiler: + +- `html` +- `SECTION_ORDER` +- `_section_entries` +- `_render_entry` +- `_truncate_lines` +- `_clean` + +Keep fetch helpers: + +- `DEFAULT_RECENT_SESSIONS` +- `DEFAULT_MAX_ITEMS` +- `DEFAULT_MAX_CHARS` +- `project_metadata_filter` +- `build_session_context` +- `_query_items` +- `_raw_data_entry` +- `_item_entry` +- `_field` +- `_text` + +- [ ] **Step 4: Run Claude Code SessionStart tests** + +Run: + +```bash +python3 -m unittest tests/test_session_context.py -v +``` + +Expected: PASS. + +- [ ] **Step 5: Run all Claude Code integration tests** + +Run: + +```bash +python3 -m unittest discover -s tests +``` + +Expected: `Ran ... tests` and `OK`. + +- [ ] **Step 6: Commit Task 2** + +Run: + +```bash +git add memind-integrations/claude-code/scripts/lib/session_context.py memind-integrations/claude-code/tests/test_session_context.py +git commit -m "feat: compile claude code session context" +``` + +Expected: commit succeeds. + +--- + +## Task 3: Wire Claude Code Prompt Retrieval to the Compiler + +**Files:** + +- Modify: `memind-integrations/claude-code/scripts/retrieve.py` +- Modify: `memind-integrations/claude-code/tests/test_hooks.py` +- Modify: `memind-integrations/claude-code/tests/test_context_compiler.py` + +- [ ] **Step 1: Add failing prompt-retrieval compiler tests** + +Append to `ContextCompilerTest` in `memind-integrations/claude-code/tests/test_context_compiler.py`: + +```python + def test_prompt_retrieval_context_groups_agent_categories_by_execution_value(self): + from scripts.lib.context_compiler import compile_prompt_retrieval_context + + data = { + "insights": [ + {"id": "ins-leaf", "text": "Leaf insight", "tier": "LEAF"}, + {"id": "ins-root", "text": "Root insight", "tier": "ROOT"}, + ], + "items": [ + {"id": "tool-1", "text": "Use mvn -pl memind-server test for server checks.", "category": "tool", "finalScore": 0.6}, + {"id": "res-1", "text": "Retry spool events are cleared only after successful agent_timeline extraction.", "category": "resolution", "finalScore": 0.9}, + {"id": "pb-1", "text": "When hooks change, run both integration test suites.", "category": "playbook", "finalScore": 0.8}, + {"id": "dir-1", "text": "Do not default Claude Code or Codex to conversation rawdata.", "category": "directive", "finalScore": 0.7}, + {"id": "ev-1", "text": "rawdata-agent emits agent_episode segment metadata.", "category": "event", "finalScore": 0.5}, + {"id": "ev-high", "text": "A high-scoring general fact should not crowd out agent-specific sections.", "category": "event", "finalScore": 0.99}, + ], + } + + rendered = compile_prompt_retrieval_context( + data, + {"retrieveMaxEntries": 8, "retrieveMaxChars": 6000, "retrievePromptPreamble": "Relevant memories from Memind."}, + ) + + self.assertIn("", rendered) + self.assertIn("## Directives", rendered) + self.assertIn("[item:dir-1 directive] Do not default Claude Code", rendered) + self.assertIn("## Resolved Problems", rendered) + self.assertIn("[item:res-1 resolution] Retry spool events", rendered) + self.assertIn("## Agent Playbooks", rendered) + self.assertIn("[item:pb-1 playbook] When hooks change", rendered) + self.assertIn("## Tool Notes", rendered) + self.assertIn("[item:tool-1 tool] Use mvn", rendered) + self.assertIn("## Insights", rendered) + self.assertLess(rendered.index("ins-root"), rendered.index("ins-leaf")) + self.assertIn("## Memory Items", rendered) + self.assertIn("[item:ev-high event] A high-scoring general fact", rendered) + self.assertIn("[item:ev-1 event] rawdata-agent emits", rendered) + self.assertTrue(rendered.endswith("")) + + def test_prompt_retrieval_context_keeps_degraded_notice(self): + from scripts.lib.context_compiler import compile_prompt_retrieval_context + + rendered = compile_prompt_retrieval_context( + {"status": "degraded"}, + {"retrieveMaxEntries": 8, "retrieveMaxChars": 1000, "retrievePromptPreamble": ""}, + ) + + self.assertIn("Memory retrieval encountered an error", rendered) + self.assertIn("", rendered) +``` + +- [ ] **Step 2: Run compiler tests** + +Run: + +```bash +python3 -m unittest tests/test_context_compiler.py -v +``` + +Expected: PASS. Task 1 already defines `compile_prompt_retrieval_context`; a failure here means the Task 1 implementation diverged from this plan and must be corrected before wiring `retrieve.py`. + +- [ ] **Step 3: Update retrieve.py to use the compiler** + +Modify `memind-integrations/claude-code/scripts/retrieve.py`. + +Add import: + +```python +from lib.context_compiler import compile_prompt_retrieval_context +``` + +Replace `_format_context` with: + +```python +def _format_context(data, config): + return compile_prompt_retrieval_context(data, config) +``` + +Delete these old local formatter symbols from `retrieve.py` after `_format_context` delegates to the compiler: + +- `AGENT_CATEGORY_SECTIONS` +- `_item_category` +- `_group_agent_items` + +- [ ] **Step 4: Update hook formatter tests for new section order** + +In `memind-integrations/claude-code/tests/test_hooks.py`, update `test_format_context_groups_agent_memory_categories` expectations: + +```python +self.assertIn("## Directives", context) +self.assertIn("## Resolved Problems", context) +self.assertIn("## Agent Playbooks", context) +self.assertIn("## Tool Notes", context) +self.assertLess(context.index("## Directives"), context.index("## Resolved Problems")) +self.assertLess(context.index("## Resolved Problems"), context.index("## Agent Playbooks")) +``` + +Keep degraded retrieval tests unchanged. + +- [ ] **Step 5: Run Claude Code tests** + +Run: + +```bash +python3 -m unittest discover -s tests +``` + +Expected: `Ran ... tests` and `OK`. + +- [ ] **Step 6: Commit Task 3** + +Run: + +```bash +git add memind-integrations/claude-code/scripts/retrieve.py memind-integrations/claude-code/tests/test_hooks.py memind-integrations/claude-code/tests/test_context_compiler.py +git commit -m "feat: compile claude code prompt retrieval context" +``` + +Expected: commit succeeds. + +--- + +## Task 4: Port Compiler to Codex + +**Files:** + +- Create: `memind-integrations/codex/scripts/lib/context_compiler.py` +- Create: `memind-integrations/codex/tests/test_context_compiler.py` + +- [ ] **Step 1: Copy Claude Code compiler implementation to Codex** + +Copy the final content of: + +```text +memind-integrations/claude-code/scripts/lib/context_compiler.py +``` + +to: + +```text +memind-integrations/codex/scripts/lib/context_compiler.py +``` + +The file must remain self-contained and import only standard library modules. + +- [ ] **Step 2: Copy compiler tests to Codex and adjust product-specific strings** + +Create `memind-integrations/codex/tests/test_context_compiler.py` from the Claude Code test file. It must keep the same test method names and the same behavioral assertions so Claude Code and Codex cannot drift. Use Codex-specific examples where text names the host: + +```python +"Completed SessionStart context injection for Codex." +"After changing Codex hooks, run the Codex integration unittest suite and git diff --check." +``` + +The import must be: + +```python +from scripts.lib.context_compiler import compile_session_start_context +``` + +Add this parity test to the Codex file so future changes keep the public compiler API aligned: + +```python + def test_compiler_exports_match_claude_code_contract(self): + import scripts.lib.context_compiler as compiler + + self.assertTrue(callable(compiler.compile_session_start_context)) + self.assertTrue(callable(compiler.compile_prompt_retrieval_context)) +``` + +- [ ] **Step 3: Run Codex compiler tests** + +Run: + +```bash +UV_CACHE_DIR=.uv-cache uv run --python /opt/homebrew/bin/python3.12 python -m unittest tests/test_context_compiler.py -v +``` + +Expected: PASS. + +- [ ] **Step 4: Commit Task 4** + +Run: + +```bash +git add memind-integrations/codex/scripts/lib/context_compiler.py memind-integrations/codex/tests/test_context_compiler.py +git commit -m "feat: add codex agent context compiler" +``` + +Expected: commit succeeds. + +--- + +## Task 5: Wire Codex SessionStart and Prompt Retrieval + +**Files:** + +- Modify: `memind-integrations/codex/scripts/lib/session_context.py` +- Modify: `memind-integrations/codex/scripts/retrieve.py` +- Modify: `memind-integrations/codex/tests/test_session_context.py` +- Modify: `memind-integrations/codex/tests/test_hooks.py` + +- [ ] **Step 1: Wire Codex session_context.py** + +Modify `memind-integrations/codex/scripts/lib/session_context.py` with the same wiring used in Claude Code: + +```python +from lib.context_compiler import compile_session_start_context +``` + +Replace `render_session_context` with: + +```python +def render_session_context(context, config): + return compile_session_start_context(context, config) +``` + +Delete these old rendering-only symbols from Codex `session_context.py` after `render_session_context` delegates to the compiler: + +- `html` +- `SECTION_ORDER` +- `_section_entries` +- `_render_entry` +- `_truncate_lines` +- `_clean` + +- [ ] **Step 2: Wire Codex retrieve.py** + +Modify `memind-integrations/codex/scripts/retrieve.py`. + +Add: + +```python +from lib.context_compiler import compile_prompt_retrieval_context +``` + +Replace `_format_context` with: + +```python +def _format_context(data, config): + return compile_prompt_retrieval_context(data, config) +``` + +Delete these old local formatter symbols from Codex `retrieve.py` after `_format_context` delegates to the compiler: + +- `AGENT_CATEGORY_SECTIONS` +- `_item_category` +- `_group_agent_items` + +- [ ] **Step 3: Update Codex SessionStart tests** + +In `memind-integrations/codex/tests/test_session_context.py`, update the fake rawdata to include a deterministic timestamp: + +```python +types.SimpleNamespace( + id="rd-1", + caption="Fixed Codex agent timeline flushing.", + metadata={}, + created_at="2026-05-27T01:00:00Z", +) +``` + +Then update rendered assertions to expect the improved preamble and dated rawdata citation: + +```python +self.assertIn("Historical Memind project memory", rendered) +self.assertIn("Current user instructions and repository files take precedence", rendered) +self.assertIn("Verify old implementation details against the working tree", rendered) +self.assertIn("[rawdata:rd-1, 2026-05-27] Fixed Codex", rendered) +``` + +- [ ] **Step 4: Update Codex hook formatter tests** + +In `memind-integrations/codex/tests/test_hooks.py`, update `test_format_context_groups_agent_memory_categories` with the same section-order assertions used for Claude Code: + +```python +self.assertIn("## Directives", context) +self.assertIn("## Resolved Problems", context) +self.assertIn("## Agent Playbooks", context) +self.assertIn("## Tool Notes", context) +``` + +- [ ] **Step 5: Run Codex tests** + +Run: + +```bash +UV_CACHE_DIR=.uv-cache uv run --python /opt/homebrew/bin/python3.12 python -m unittest discover -s tests +``` + +Expected: `Ran ... tests` and `OK`. + +- [ ] **Step 6: Commit Task 5** + +Run: + +```bash +git add memind-integrations/codex/scripts/lib/session_context.py memind-integrations/codex/scripts/retrieve.py memind-integrations/codex/tests/test_session_context.py memind-integrations/codex/tests/test_hooks.py +git commit -m "feat: compile codex memory context" +``` + +Expected: commit succeeds. + +--- + +## Task 6: Update Codex Installer and Configuration Tests + +**Files:** + +- Modify: `memind-integrations/codex/install.sh` +- Modify: `memind-integrations/codex/tests/test_installer.py` + +- [ ] **Step 1: Add failing installer test for context compiler file** + +Add this test method to `InstallerTest` in `memind-integrations/codex/tests/test_installer.py`. This must read `install.sh` directly because local `--source-root` installation copies the whole `scripts/` directory and would not catch a missing remote download list entry: + +```python + def test_remote_install_file_list_includes_context_compiler(self): + install_script = (ROOT / "install.sh").read_text() + + self.assertIn('"scripts/lib/context_compiler.py"', install_script) +``` + +- [ ] **Step 2: Run installer tests to verify failure** + +Run: + +```bash +UV_CACHE_DIR=.uv-cache uv run --python /opt/homebrew/bin/python3.12 python -m unittest tests/test_installer.py -v +``` + +Expected: FAIL because `install.sh` does not yet include `scripts/lib/context_compiler.py`. + +- [ ] **Step 3: Add context_compiler.py to installer file list** + +Modify `memind-integrations/codex/install.sh` in `download_remote_install()` and insert: + +```bash +"scripts/lib/context_compiler.py" +``` + +near the other `scripts/lib/*.py` entries. + +- [ ] **Step 4: Run installer tests** + +Run: + +```bash +UV_CACHE_DIR=.uv-cache uv run --python /opt/homebrew/bin/python3.12 python -m unittest tests/test_installer.py -v +``` + +Expected: PASS. + +- [ ] **Step 5: Commit Task 6** + +Run: + +```bash +git add memind-integrations/codex/install.sh memind-integrations/codex/tests/test_installer.py +git commit -m "fix: install codex context compiler" +``` + +Expected: commit succeeds. + +--- + +## Task 7: Update Documentation and Examples + +**Files:** + +- Modify: `memind-integrations/claude-code/README.md` +- Modify: `memind-integrations/codex/README.md` + +- [ ] **Step 1: Update SessionStart context example in Claude Code README** + +In `memind-integrations/claude-code/README.md`, replace the existing SessionStart context example with: + +```text + +Historical Memind project memory. Use only when directly helpful. Current user instructions and repository files take precedence. Verify old implementation details against the working tree before relying on them. + +## Continue From +- [rawdata:rd-1, 2026-05-27] Completed SessionStart context injection for Claude Code and Codex. + +## Must Follow +- [item:101 directive, 2026-05-27] Keep userId and agentId stable; use metadata.projectSlug for project isolation. + +## Watch Outs +- [item:102 resolution, 2026-05-27] Codex tests must run with Python 3.12; older Python can fail on modern type syntax. + +## Reusable Playbooks +- [item:103 playbook, 2026-05-27] After changing Claude Code or Codex hooks, run both integration unittest suites and git diff --check. + +## Useful Facts +- [item:104 event, 2026-05-27] SessionStart is read-only: it queries memory and injects context without writing rawdata. + +``` + +- [ ] **Step 2: Update prompt retrieval example in Claude Code README** + +In the retrieval section, update the `` example to show: + +```text + +Relevant memories from Memind. Use only when directly helpful: + +## Directives +- [item:201 directive] Do not default Claude Code or Codex to conversation rawdata. + +## Resolved Problems +- [item:202 resolution] Retry spool events are cleared only after successful agent_timeline extraction. + +## Agent Playbooks +- [item:203 playbook] When hooks change, run both integration test suites and git diff --check. + +## Tool Notes +- [item:204 tool] Use Python 3.12 for the Codex integration test suite. + +## Insights +- [insight:301 root] Coding-agent integrations share memory through stable userId and agentId. + +## Memory Items +- [item:205 event] rawdata-agent emits agent_episode segment metadata. + +``` + +- [ ] **Step 3: Mirror documentation updates in Codex README** + +Apply the same structure to `memind-integrations/codex/README.md`, using Codex wording where the surrounding text references the host. + +- [ ] **Step 4: Commit Task 7** + +Run: + +```bash +git add memind-integrations/claude-code/README.md memind-integrations/codex/README.md +git commit -m "docs: explain agent context compiler output" +``` + +Expected: commit succeeds. + +--- + +## Task 8: Final Verification + +**Files:** + +- Verify all changed files. + +- [ ] **Step 1: Run Claude Code full test suite** + +Run: + +```bash +python3 -m unittest discover -s tests +``` + +from: + +```text +memind-integrations/claude-code +``` + +Expected: `OK`. + +- [ ] **Step 2: Run Codex full test suite** + +Run: + +```bash +UV_CACHE_DIR=.uv-cache uv run --python /opt/homebrew/bin/python3.12 python -m unittest discover -s tests +``` + +from: + +```text +memind-integrations/codex +``` + +Expected: `OK`. + +- [ ] **Step 3: Check whitespace** + +Run: + +```bash +git diff --check +``` + +Expected: no output and exit code 0. + +- [ ] **Step 4: Check staged/untracked files** + +Run: + +```bash +git status --short --branch +``` + +Expected: + +- Current branch remains `feat/rawdata-agent-memory`. +- No `__pycache__` or `.pyc` files are staged. +- Only intentional source, test, installer, and README changes appear. + +- [ ] **Step 5: Push final branch** + +Run: + +```bash +git push +``` + +Expected: remote `feat/rawdata-agent-memory` advances successfully. + +--- + +## Self-Review Checklist + +- Spec coverage: + - SessionStart project-continuity compiler: Tasks 1, 2, 4, 5. + - Prompt retrieval compiler: Tasks 1, 3, 4, 5. + - Claude Code and Codex parity: Tasks 4 and 5. + - No Memind core/OpenAPI/rawdata-agent changes: File Structure and all tasks. + - Installer safety for Codex: Task 6. + - Documentation and examples: Task 7. + - Verification: Task 8. +- Placeholder scan: no unfinished placeholder markers or unspecified implementation placeholders are present. +- Type consistency: + - Public compiler functions are `compile_session_start_context(context, config)` and `compile_prompt_retrieval_context(data, config)`. + - Context keys stay compatible with existing `session_context.py`: `recentRawData`, `items.directive`, `items.watchOut`, `items.playbook`, `items.fact`. + - Existing config keys remain valid: `sessionContextMaxChars`, `retrieveMaxEntries`, `retrieveMaxChars`, `retrievePromptPreamble`. From b77c965c9b305f9d035d7b5899df776258695d19 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 13:59:44 +0800 Subject: [PATCH 38/54] feat(agent): absorb deterministic tool telemetry --- ...-05-28-rawdata-agent-toolcall-telemetry.md | 1239 +++++++++++++++++ .../specs/2026-05-24-rawdata-agent-design.md | 2 + memind-integrations/claude-code/README.md | 9 +- .../claude-code/scripts/lib/agent_timeline.py | 120 +- .../claude-code/tests/test_agent_timeline.py | 100 ++ .../tests/test_context_compiler.py | 14 + memind-integrations/codex/README.md | 8 +- .../codex/scripts/lib/agent_timeline.py | 120 +- .../codex/tests/test_agent_timeline.py | 106 ++ .../codex/tests/test_context_compiler.py | 14 + .../agent/chunk/AgentSegmentFormatter.java | 1 + .../agent/chunk/AgentToolTelemetry.java | 205 +++ .../agent/content/AgentTimelineContent.java | 3 + .../agent/item/AgentMemoryItemFactory.java | 12 +- .../rawdata/agent/model/AgentEvent.java | 3 + .../agent/privacy/AgentEventRedactor.java | 3 + .../chunk/AgentEpisodeAssemblerTest.java | 6 + .../agent/chunk/AgentEpisodeTestSupport.java | 3 + .../chunk/AgentSegmentFormatterTest.java | 160 +++ .../agent/chunk/AgentTimelineChunkerTest.java | 3 + .../content/AgentTimelineContentTest.java | 122 ++ ...gentExtractionPipelineIntegrationTest.java | 3 + .../item/AgentItemExtractionStrategyTest.java | 68 +- .../agent/privacy/AgentEventRedactorTest.java | 44 + 24 files changed, 2339 insertions(+), 29 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-28-rawdata-agent-toolcall-telemetry.md create mode 100644 memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentToolTelemetry.java diff --git a/docs/superpowers/plans/2026-05-28-rawdata-agent-toolcall-telemetry.md b/docs/superpowers/plans/2026-05-28-rawdata-agent-toolcall-telemetry.md new file mode 100644 index 00000000..cf397ae9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-rawdata-agent-toolcall-telemetry.md @@ -0,0 +1,1239 @@ +# RawData Agent ToolCall Telemetry Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Absorb the useful deterministic parts of `rawdata-toolcall` into `rawdata-agent` so Claude Code and Codex timelines produce richer file/tool-aware evidence without adding extra LLM calls or duplicating raw data paths. + +**Architecture:** Claude Code and Codex continue to ingest only `agent_timeline` raw data. Tool-call telemetry is normalized at hook time, preserved as typed `AgentEvent` fields, aggregated into agent episode segment metadata, and used to improve deterministic `tool` items. `rawdata-toolcall` remains a separate compatibility plugin for pure tool logs. + +**Tech Stack:** Python integration hooks, Java 21 records, JUnit 5, AssertJ, Maven, Memind rawdata plugin architecture. + +--- + +## Scope And Non-Goals + +This plan implements only deterministic tool telemetry absorption into `rawdata-agent`. + +In scope: + +- Capture `durationMs`, `inputTokens`, `outputTokens`, and `contentHash` in Claude Code and Codex normalized tool events when hook payloads expose them. +- Use redacted/canonicalized semantic tool input and output to compute `contentHash`; exclude volatile telemetry fields such as duration and token usage from the hash. +- Add `inputTokens`, `outputTokens`, and `contentHash` to `AgentEvent`; reuse existing `occurredAt` as the tool call timestamp and existing `durationMs` as duration. +- Add capped `toolRecords`, `toolStats`, and `toolGroups` metadata to formatted `agent_episode` segments. +- Copy useful tool telemetry metadata into deterministic `category=tool` and `category=resolution` memory items. +- Keep the existing `agent_timeline` caption and item extraction LLM flow unchanged. + +Out of scope: + +- No `rawdata-toolcall` LLM extraction inside `rawdata-agent`. +- No `ToolCallChunker` by-tool segmentation for `agent_timeline`. +- No Claude Code/Codex double-ingestion into both `rawdata-agent` and `rawdata-toolcall`. +- No PreToolUse file context query/injection implementation in this plan. +- No OpenAPI metadata filter changes in this plan. + +## File Map + +- Modify `memind-integrations/claude-code/scripts/lib/agent_timeline.py` + - Extract optional telemetry fields from hook payloads. + - Compute `contentHash` from redacted canonical tool content. + - Keep redaction before hashing. + +- Modify `memind-integrations/codex/scripts/lib/agent_timeline.py` + - Mirror Claude Code normalization behavior. + +- Modify `memind-integrations/claude-code/tests/test_agent_timeline.py` + - Cover telemetry normalization and secret-safe hash behavior. + +- Modify `memind-integrations/codex/tests/test_agent_timeline.py` + - Mirror Claude Code tests with Codex source client. + +- Modify `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEvent.java` + - Add typed `inputTokens`, `outputTokens`, and `contentHash` fields. + +- Modify `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java` + - Include new typed telemetry fields in canonical event identity. + +- Modify `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactor.java` + - Preserve the new typed fields after redaction. + +- Create `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentToolTelemetry.java` + - Build capped `toolRecords`, `toolStats`, and `toolGroups` metadata from episode events. + +- Modify `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java` + - Add telemetry metadata to agent episode segments. + +- Modify `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java` + - Copy telemetry metadata into deterministic item metadata. + - Improve deterministic tool item text when validation failed and then passed. + +- Modify Java tests under `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/...` + - Add serialization, redaction, formatter, and deterministic item tests. + +- Modify `memind-integrations/claude-code/README.md` and `memind-integrations/codex/README.md` + - Document the strengthened `rawdata-agent` tool telemetry path and clarify that `rawdata-toolcall` remains pure-tool-log compatibility. + +--- + +### Task 1: Add Hook Telemetry Tests For Claude Code And Codex + +**Files:** +- Modify: `memind-integrations/claude-code/tests/test_agent_timeline.py` +- Modify: `memind-integrations/codex/tests/test_agent_timeline.py` + +- [ ] **Step 1: Add a Claude Code telemetry normalization test** + +Add this test method to `AgentTimelineTest` in `memind-integrations/claude-code/tests/test_agent_timeline.py`: + +```python +def test_normalizes_tool_telemetry_and_content_hash(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "Bash", + "tool_input": {"command": "npm test payment"}, + "tool_response": { + "exit_code": 0, + "stdout": "passed", + "duration_ms": 1234, + "usage": {"input_tokens": 11, "output_tokens": 22}, + }, + "timestamp": "2026-05-24T10:00:00Z", + }, + seq=1, + ) + + self.assertEqual(event["durationMs"], 1234) + self.assertEqual(event["inputTokens"], 11) + self.assertEqual(event["outputTokens"], 22) + self.assertTrue(event["contentHash"].startswith("sha256:")) + self.assertEqual(len(event["contentHash"]), len("sha256:") + 64) + self.assertEqual(event["output"], '{"stdout": "passed"}') + self.assertEqual(event["metadata"]["normalizationVersion"], 1) +``` + +- [ ] **Step 2: Add a Claude Code secret-safe hash test** + +Add this test method to the same class: + +```python +def test_content_hash_uses_redacted_payload(self): + first = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "CustomTool", + "tool_input": {"token": "Bearer first-secret-value"}, + "tool_response": {"result": "ok"}, + "timestamp": "2026-05-24T10:00:00Z", + }, + seq=1, + ) + second = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "CustomTool", + "tool_input": {"token": "Bearer second-secret-value"}, + "tool_response": {"result": "ok"}, + "timestamp": "2026-05-24T10:00:01Z", + }, + seq=2, + ) + + self.assertEqual(first["contentHash"], second["contentHash"]) + self.assertIn("[REDACTED:bearer_token]", first["input"]) + self.assertIn("[REDACTED:bearer_token]", second["input"]) +``` + +- [ ] **Step 3: Add a Claude Code telemetry-stable hash test** + +Add this test method to the same class: + +```python +def test_content_hash_ignores_volatile_telemetry_fields(self): + first = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "Bash", + "tool_input": {"command": "npm test payment"}, + "tool_response": { + "exit_code": 0, + "stdout": "passed", + "duration_ms": 100, + "metadata": {"duration_ms": 100}, + "usage": {"input_tokens": 11, "output_tokens": 22}, + }, + "timestamp": "2026-05-24T10:00:00Z", + }, + seq=1, + ) + second = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "Bash", + "tool_input": {"command": "npm test payment"}, + "tool_response": { + "exit_code": 0, + "stdout": "passed", + "duration_ms": 999, + "metadata": {"duration_ms": 999}, + "usage": {"input_tokens": 100, "output_tokens": 200}, + }, + "timestamp": "2026-05-24T10:00:01Z", + }, + seq=2, + ) + + self.assertEqual(first["contentHash"], second["contentHash"]) + self.assertNotIn("duration_ms", first.get("output", "")) + self.assertNotIn("metadata", first.get("output", "")) + self.assertNotIn("usage", first.get("output", "")) +``` + +- [ ] **Step 4: Add equivalent Codex tests** + +Add the same three tests to `memind-integrations/codex/tests/test_agent_timeline.py`, with `"source_client": "codex"` in each hook input. Assert `event["metadata"]["sourceClient"] == "codex"` in the first test. + +- [ ] **Step 5: Run Python tests and verify failure** + +Run: + +```bash +python3 -m unittest memind-integrations/claude-code/tests/test_agent_timeline.py memind-integrations/codex/tests/test_agent_timeline.py +``` + +Expected: FAIL because `durationMs`, `inputTokens`, `outputTokens`, or `contentHash` are not emitted yet. + +--- + +### Task 2: Implement Hook Telemetry Normalization + +**Files:** +- Modify: `memind-integrations/claude-code/scripts/lib/agent_timeline.py` +- Modify: `memind-integrations/codex/scripts/lib/agent_timeline.py` + +- [ ] **Step 1: Add telemetry helpers to Claude Code normalizer** + +Add these helpers near `_json_text` in `memind-integrations/claude-code/scripts/lib/agent_timeline.py`: + +```python +def _number_value(*values): + for value in values: + if isinstance(value, bool): + continue + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str) and value.strip().isdigit(): + return int(value.strip()) + return None + + +def _nested_number(mapping, *path): + current = mapping + for key in path: + if not isinstance(current, dict): + return None + current = current.get(key) + return _number_value(current) + + +def _tool_telemetry(hook_input, tool_response): + usage = tool_response.get("usage") if isinstance(tool_response, dict) else {} + return { + "durationMs": _number_value( + hook_input.get("duration_ms"), + hook_input.get("durationMs"), + tool_response.get("duration_ms") if isinstance(tool_response, dict) else None, + tool_response.get("durationMs") if isinstance(tool_response, dict) else None, + _nested_number(tool_response, "metadata", "duration_ms"), + _nested_number(tool_response, "metadata", "durationMs"), + ), + "inputTokens": _number_value( + hook_input.get("input_tokens"), + hook_input.get("inputTokens"), + tool_response.get("input_tokens") if isinstance(tool_response, dict) else None, + tool_response.get("inputTokens") if isinstance(tool_response, dict) else None, + usage.get("input_tokens") if isinstance(usage, dict) else None, + usage.get("inputTokens") if isinstance(usage, dict) else None, + ), + "outputTokens": _number_value( + hook_input.get("output_tokens"), + hook_input.get("outputTokens"), + tool_response.get("output_tokens") if isinstance(tool_response, dict) else None, + tool_response.get("outputTokens") if isinstance(tool_response, dict) else None, + usage.get("output_tokens") if isinstance(usage, dict) else None, + usage.get("outputTokens") if isinstance(usage, dict) else None, + ), + } + + +VOLATILE_TOOL_OUTPUT_KEYS = { + "exit_code", + "exitCode", + "duration_ms", + "durationMs", + "input_tokens", + "inputTokens", + "output_tokens", + "outputTokens", + "usage", +} + + +def _semantic_tool_output(raw_tool_response): + if isinstance(raw_tool_response, list): + return [_semantic_tool_output(value) for value in raw_tool_response] + if not isinstance(raw_tool_response, dict): + return raw_tool_response + result = {} + for key, value in raw_tool_response.items(): + if key in VOLATILE_TOOL_OUTPUT_KEYS or value is None: + continue + normalized = _semantic_tool_output(value) + if normalized not in (None, {}, []): + result[key] = normalized + return result + + +def _content_hash(tool_name, normalized_input, normalized_output): + stable = json.dumps( + { + "toolName": tool_name or "", + "input": normalized_input or "", + "output": normalized_output or "", + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + return "sha256:" + hashlib.sha256(stable.encode("utf-8")).hexdigest() +``` + +- [ ] **Step 2: Emit telemetry in `normalize_hook_event`** + +In `normalize_hook_event`, use `_semantic_tool_output(raw_tool_response)` when building `output` so typed telemetry does not duplicate into output text or destabilize `contentHash`: + +```python + output = _semantic_tool_output(raw_tool_response) +``` + +After redacted input/output values are computed and before metadata is assigned, set top-level telemetry fields: + +```python + telemetry = _tool_telemetry(hook_input, tool_response) + if telemetry.get("durationMs") is not None: + event["durationMs"] = telemetry["durationMs"] + if telemetry.get("inputTokens") is not None: + event["inputTokens"] = telemetry["inputTokens"] + if telemetry.get("outputTokens") is not None: + event["outputTokens"] = telemetry["outputTokens"] + event["contentHash"] = _content_hash( + tool_name, + event.get("command") if event.get("command") is not None else event.get("input"), + event.get("output"), + ) +``` + +Keep `contentHash` based on redacted values already stored on `event`; do not hash raw input/output. + +- [ ] **Step 3: Mirror the same changes in Codex normalizer** + +Apply the same helper methods and `normalize_hook_event` update to `memind-integrations/codex/scripts/lib/agent_timeline.py`. + +- [ ] **Step 4: Run Python tests and verify pass** + +Run: + +```bash +python3 -m unittest memind-integrations/claude-code/tests/test_agent_timeline.py memind-integrations/codex/tests/test_agent_timeline.py +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add memind-integrations/claude-code/scripts/lib/agent_timeline.py memind-integrations/codex/scripts/lib/agent_timeline.py memind-integrations/claude-code/tests/test_agent_timeline.py memind-integrations/codex/tests/test_agent_timeline.py +git commit -m "feat(agent): capture tool telemetry in timeline hooks" +``` + +--- + +### Task 3: Add AgentEvent Typed Telemetry Fields + +**Files:** +- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEvent.java` +- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java` +- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactor.java` +- Modify Java tests constructing `new AgentEvent(...)` +- Test: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java` +- Test: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactorTest.java` + +- [ ] **Step 1: Add a failing Jackson preservation test** + +Add this test to `AgentTimelineContentTest`: + +```java +@Test +void jacksonShouldPreserveToolTelemetryFields() throws Exception { + String json = + """ + { + "type": "agent_timeline", + "sourceClient": "claude-code", + "sessionId": "session-1", + "agentTurnId": "turn-1", + "timelineId": "timeline-1", + "events": [ + { + "eventId": "event-tool", + "seq": 1, + "kind": "command", + "toolName": "Bash", + "command": "npm test payment", + "status": "success", + "durationMs": 1234, + "inputTokens": 11, + "outputTokens": 22, + "contentHash": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + } + ] + } + """; + + RawContent decoded = OBJECT_MAPPER.readValue(json, RawContent.class); + + AgentEvent event = ((AgentTimelineContent) decoded).events().getFirst(); + assertThat(event.durationMs()).isEqualTo(1234L); + assertThat(event.inputTokens()).isEqualTo(11); + assertThat(event.outputTokens()).isEqualTo(22); + assertThat(event.contentHash()) + .isEqualTo("sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); +} +``` + +- [ ] **Step 2: Add a failing canonical identity test** + +Add this test to `AgentTimelineContentTest`: + +```java +@Test +void contentIdShouldIncludeTypedToolTelemetryFields() { + AgentProject project = new AgentProject("payment-service", "/repo/payment", null, Map.of()); + AgentEvent first = + new AgentEvent( + "e1", + 1, + AgentEventKind.COMMAND, + Instant.parse("2026-05-24T10:00:00Z"), + null, + "Bash", + null, + "passed", + AgentEventStatus.SUCCESS, + 1234L, + 11, + 22, + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + null, + "run", + "npm test payment", + 0, + Map.of()); + AgentEvent second = + new AgentEvent( + "e1", + 1, + AgentEventKind.COMMAND, + Instant.parse("2026-05-24T10:00:00Z"), + null, + "Bash", + null, + "passed", + AgentEventStatus.SUCCESS, + 1234L, + 11, + 23, + "sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", + null, + "run", + "npm test payment", + 0, + Map.of()); + + AgentTimelineContent firstContent = + new AgentTimelineContent( + "claude-code", + "1.0", + "session-123", + "turn-1", + "timeline-1", + project, + List.of(first)); + AgentTimelineContent secondContent = + new AgentTimelineContent( + "claude-code", + "1.0", + "session-123", + "turn-1", + "timeline-1", + project, + List.of(second)); + + assertThat(firstContent.getContentId()).isNotEqualTo(secondContent.getContentId()); +} +``` + +- [ ] **Step 3: Run the Java test and verify failure** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentTimelineContentTest test +``` + +Expected: FAIL because `inputTokens`, `outputTokens`, and `contentHash` are not typed fields yet. + +- [ ] **Step 4: Update `AgentEvent` record** + +Change `AgentEvent` to include the new fields immediately after `durationMs`: + +```java +public record AgentEvent( + String eventId, + Integer seq, + AgentEventKind kind, + Instant occurredAt, + String text, + String toolName, + String input, + String output, + AgentEventStatus status, + Long durationMs, + Integer inputTokens, + Integer outputTokens, + String contentHash, + String path, + String operation, + String command, + Integer exitCode, + Map metadata) { +``` + +- [ ] **Step 5: Include telemetry fields in canonical event identity** + +In `AgentTimelineContent.canonicalEvent`, include the new fields immediately after `durationMs`: + +```java + normalized(event.durationMs()), + normalized(event.inputTokens()), + normalized(event.outputTokens()), + normalized(event.contentHash()), + normalized(event.path()), +``` + +- [ ] **Step 6: Preserve fields in `AgentEventRedactor`** + +Update the `new AgentEvent(...)` call in `AgentEventRedactor.redact` to pass the new fields: + +```java + event.durationMs(), + event.inputTokens(), + event.outputTokens(), + event.contentHash(), + event.path(), +``` + +- [ ] **Step 7: Update all test constructors** + +Every existing `new AgentEvent(...)` call that already passes `durationMs` must insert the three new fields immediately after that `durationMs` value: + +```java +null, +null, +null, +``` + +For telemetry-specific constructors, keep the existing duration value and then pass concrete telemetry values: + +```java +1234L, +11, +22, +"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", +``` + +- [ ] **Step 8: Run Java tests and verify pass** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentTimelineContentTest,AgentEventRedactorTest test +``` + +Expected: PASS. + +- [ ] **Step 9: Commit** + +Run: + +```bash +git add memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEvent.java memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactor.java memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent +git commit -m "feat(agent): add typed tool telemetry fields" +``` + +--- + +### Task 4: Add Deterministic Tool Telemetry Aggregation + +**Files:** +- Create: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentToolTelemetry.java` +- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java` +- Test: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java` + +- [ ] **Step 1: Add a failing segment metadata test** + +Extend `shouldFormatEpisodeTextAndMetadataDeterministically` in `AgentSegmentFormatterTest` with assertions: + +```java +assertThat(formatted.metadata().get("toolRecords")) + .asList() + .contains( + Map.of( + "eventId", + "e2", + "seq", + 2, + "toolName", + "Bash", + "kind", + "command", + "status", + "failed", + "durationMs", + 10L, + "command", + "npm test payment", + "outputPreview", + "rounding mismatch")); +assertThat(formatted.metadata().get("toolStats")) + .isEqualTo( + Map.of( + "Bash", + Map.of( + "callCount", + 2, + "successCount", + 1, + "failCount", + 1, + "avgDurationMs", + 10L), + "Edit", + Map.of( + "callCount", + 1, + "successCount", + 1, + "failCount", + 0, + "avgDurationMs", + 10L))); +assertThat(formatted.metadata().get("toolGroups")) + .asList() + .contains( + Map.of( + "toolName", + "Bash", + "callCount", + 2, + "successCount", + 1, + "failCount", + 1, + "commands", + List.of("npm test payment"))); +``` + +- [ ] **Step 2: Run formatter test and verify failure** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentSegmentFormatterTest test +``` + +Expected: FAIL because `toolRecords`, `toolStats`, and `toolGroups` are absent. + +- [ ] **Step 3: Create `AgentToolTelemetry`** + +Create `AgentToolTelemetry.java`: + +```java +package com.openmemind.ai.memory.plugin.rawdata.agent.chunk; + +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEvent; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventStatus; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; + +final class AgentToolTelemetry { + + private static final int MAX_TOOL_RECORDS = 40; + private static final int MAX_TOOL_STATS = 20; + private static final int MAX_TOOL_GROUPS = 20; + private static final int MAX_GROUP_VALUES = 20; + private static final int MAX_OUTPUT_PREVIEW_CHARS = 240; + + private AgentToolTelemetry() {} + + static Map metadata(List events) { + List toolEvents = + events == null + ? List.of() + : events.stream().filter(AgentToolTelemetry::hasToolEvidence).toList(); + if (toolEvents.isEmpty()) { + return Map.of(); + } + var metadata = new LinkedHashMap(); + metadata.put("toolRecords", toolRecords(toolEvents)); + metadata.put("toolStats", toolStats(toolEvents)); + metadata.put("toolGroups", toolGroups(toolEvents)); + return Map.copyOf(metadata); + } + + private static boolean hasToolEvidence(AgentEvent event) { + return event != null && event.toolName() != null && !event.toolName().isBlank(); + } + + private static List> toolRecords(List events) { + var records = new ArrayList>(); + for (AgentEvent event : events.stream().limit(MAX_TOOL_RECORDS).toList()) { + var record = new LinkedHashMap(); + put(record, "eventId", event.eventId()); + put(record, "seq", event.seq()); + put(record, "toolName", event.toolName()); + put(record, "kind", event.kind() == null ? null : event.kind().wireValue()); + put(record, "status", event.status() == null ? null : event.status().wireValue()); + put(record, "durationMs", event.durationMs()); + put(record, "inputTokens", event.inputTokens()); + put(record, "outputTokens", event.outputTokens()); + put(record, "contentHash", event.contentHash()); + put(record, "path", event.path()); + put(record, "operation", event.operation()); + put(record, "command", event.command()); + put(record, "outputPreview", concise(event.output())); + records.add(Map.copyOf(record)); + } + return List.copyOf(records); + } + + private static Map> toolStats(List events) { + var grouped = new LinkedHashMap>(); + events.forEach(event -> grouped.computeIfAbsent(event.toolName(), key -> new ArrayList<>()).add(event)); + var stats = new LinkedHashMap>(); + grouped.entrySet().stream() + .limit(MAX_TOOL_STATS) + .forEach(groupEntry -> { + String toolName = groupEntry.getKey(); + List toolEvents = groupEntry.getValue(); + int success = countStatus(toolEvents, AgentEventStatus.SUCCESS); + int failed = countStatus(toolEvents, AgentEventStatus.FAILED); + var durations = + toolEvents.stream() + .map(AgentEvent::durationMs) + .filter(value -> value != null) + .mapToLong(Long::longValue) + .summaryStatistics(); + var stat = new LinkedHashMap(); + stat.put("callCount", toolEvents.size()); + stat.put("successCount", success); + stat.put("failCount", failed); + if (durations.getCount() > 0) { + stat.put("avgDurationMs", Math.round(durations.getAverage())); + } + sum(toolEvents, AgentEvent::inputTokens).ifPresent(value -> stat.put("inputTokens", value)); + sum(toolEvents, AgentEvent::outputTokens).ifPresent(value -> stat.put("outputTokens", value)); + stats.put(toolName, Map.copyOf(stat)); + }); + return Map.copyOf(stats); + } + + private static List> toolGroups(List events) { + var grouped = new LinkedHashMap>(); + events.forEach(event -> grouped.computeIfAbsent(event.toolName(), key -> new ArrayList<>()).add(event)); + var groups = new ArrayList>(); + grouped.entrySet().stream() + .limit(MAX_TOOL_GROUPS) + .forEach(entry -> { + String toolName = entry.getKey(); + List toolEvents = entry.getValue(); + var commands = new LinkedHashSet(); + var paths = new LinkedHashSet(); + toolEvents.stream() + .map(AgentEvent::command) + .filter(AgentToolTelemetry::hasText) + .limit(MAX_GROUP_VALUES) + .forEach(commands::add); + toolEvents.stream() + .map(AgentEvent::path) + .filter(AgentToolTelemetry::hasText) + .limit(MAX_GROUP_VALUES) + .forEach(paths::add); + var group = new LinkedHashMap(); + group.put("toolName", toolName); + group.put("callCount", toolEvents.size()); + group.put("successCount", countStatus(toolEvents, AgentEventStatus.SUCCESS)); + group.put("failCount", countStatus(toolEvents, AgentEventStatus.FAILED)); + if (!commands.isEmpty()) { + group.put("commands", List.copyOf(commands)); + } + if (!paths.isEmpty()) { + group.put("paths", List.copyOf(paths)); + } + groups.add(Map.copyOf(group)); + }); + return List.copyOf(groups); + } + + private static int countStatus(List events, AgentEventStatus status) { + return (int) events.stream().filter(event -> event.status() == status).count(); + } + + private static java.util.Optional sum( + List events, java.util.function.Function getter) { + var values = events.stream().map(getter).filter(value -> value != null).toList(); + if (values.isEmpty()) { + return java.util.Optional.empty(); + } + return java.util.Optional.of(values.stream().mapToInt(Integer::intValue).sum()); + } + + private static void put(Map target, String key, Object value) { + if (value != null && (!(value instanceof String text) || !text.isBlank())) { + target.put(key, value); + } + } + + private static String concise(String value) { + if (!hasText(value)) { + return null; + } + String normalized = value.replaceAll("\\s+", " ").trim(); + return normalized.length() <= MAX_OUTPUT_PREVIEW_CHARS + ? normalized + : normalized.substring(0, MAX_OUTPUT_PREVIEW_CHARS); + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} +``` + +- [ ] **Step 4: Add telemetry metadata in `AgentSegmentFormatter`** + +In `metadata(AgentTimelineContent timeline, AgentEpisode episode)`, after `fileEvents` is added, merge telemetry metadata: + +```java +metadata.putAll(AgentToolTelemetry.metadata(episode.events())); +``` + +- [ ] **Step 5: Run formatter test and verify pass** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentSegmentFormatterTest test +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +Run: + +```bash +git add memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java +git commit -m "feat(agent): aggregate tool telemetry in episode metadata" +``` + +--- + +### Task 5: Improve Deterministic Tool Items With Telemetry + +**Files:** +- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java` +- Test: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java` + +- [ ] **Step 1: Add a failing deterministic item metadata test** + +In `AgentItemExtractionStrategyTest`, add these entries to the metadata returned by `successfulEpisode()`: + +```java +Map.entry( + "toolStats", + Map.of( + "Bash", + Map.of( + "callCount", + 2, + "successCount", + 1, + "failCount", + 1, + "avgDurationMs", + 10L))), +Map.entry( + "toolRecords", + List.of( + Map.of( + "eventId", + "e2", + "seq", + 2, + "toolName", + "Bash", + "status", + "failed", + "command", + "npm test payment", + "outputPreview", + "rounding mismatch"), + Map.of( + "eventId", + "e4", + "seq", + 4, + "toolName", + "Bash", + "status", + "success", + "command", + "npm test payment", + "outputPreview", + "passed"))), +Map.entry( + "toolGroups", + List.of( + Map.of( + "toolName", + "Bash", + "callCount", + 2, + "successCount", + 1, + "failCount", + 1, + "commands", + List.of("npm test payment")))) +``` + +Then assert those keys are copied to the produced `tool` item metadata: + +```java +assertThat(tool.metadata()) + .containsKeys("toolStats", "toolRecords", "toolGroups") + .containsEntry("command", "npm test payment") + .containsEntry("successCount", 1) + .containsEntry("failCount", 1); +``` + +- [ ] **Step 2: Add a failing deterministic tool content test** + +Assert that failed-then-passed validation produces a more informative tool item: + +```java +assertThat(tool.content()) + .contains("npm test payment") + .contains("failed once") + .contains("passed once") + .contains("src/payment/calc.ts"); +``` + +- [ ] **Step 3: Run item extraction test and verify failure** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentItemExtractionStrategyTest test +``` + +Expected: FAIL because telemetry metadata is not copied and the current tool content uses the simpler validation text. + +- [ ] **Step 4: Copy telemetry metadata into deterministic item metadata** + +In `baseMetadata(EpisodeMetadata episode)`, add: + +```java +copy(episode.raw(), metadata, "toolStats"); +copy(episode.raw(), metadata, "toolRecords"); +copy(episode.raw(), metadata, "toolGroups"); +``` + +- [ ] **Step 5: Improve `toolContent`** + +Replace the first `command != null && !episode.files().isEmpty()` branch with: + +```java +if (command != null && !episode.files().isEmpty()) { + String fileList = String.join(", ", episode.files()); + if (failCount > 0 || successCount > 0) { + return "Use %s to validate changes touching %s; it failed %s and passed %s in this agent episode." + .formatted(command, fileList, countWord(failCount), countWord(successCount)); + } + return "Use %s to validate changes touching %s.".formatted(command, fileList); +} +``` + +Keep existing branches for command-without-files and tool-without-command. + +- [ ] **Step 6: Run item extraction test and verify pass** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentItemExtractionStrategyTest test +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +Run: + +```bash +git add memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java +git commit -m "feat(agent): enrich deterministic tool memories with telemetry" +``` + +--- + +### Task 6: Protect Privacy, Size, And Compatibility + +**Files:** +- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactorTest.java` +- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java` +- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentToolTelemetry.java` + +- [ ] **Step 1: Add redactor preservation test** + +Add this test to `AgentEventRedactorTest`: + +```java +@Test +void shouldPreserveToolTelemetryWhenRedactingText() { + AgentEvent event = + new AgentEvent( + "e1", + 1, + AgentEventKind.COMMAND, + Instant.parse("2026-05-24T10:00:00Z"), + null, + "Bash", + null, + "Bearer secret-token-value", + AgentEventStatus.SUCCESS, + 1234L, + 11, + 22, + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + null, + null, + "npm test payment", + 0, + Map.of()); + + AgentEvent redacted = new AgentEventRedactor().redact(event); + + assertThat(redacted.durationMs()).isEqualTo(1234L); + assertThat(redacted.inputTokens()).isEqualTo(11); + assertThat(redacted.outputTokens()).isEqualTo(22); + assertThat(redacted.contentHash()).isEqualTo(event.contentHash()); + assertThat(redacted.output()).contains("[REDACTED:bearer_token]"); +} +``` + +- [ ] **Step 2: Add capped telemetry metadata tests** + +Add a test to `AgentSegmentFormatterTest` that builds 45 tool events and asserts: + +```java +assertThat(formatted.metadata().get("toolRecords")).asList().hasSize(40); +``` + +Build events with `AgentEpisodeTestSupport.event(...)` and distinct event IDs. + +Add a second test that builds 25 distinct tool names and asserts: + +```java +assertThat(((Map) formatted.metadata().get("toolStats"))).hasSize(20); +assertThat(formatted.metadata().get("toolGroups")).asList().hasSize(20); +``` + +In the same test or a focused helper test, include one tool with 25 distinct commands/paths and assert the first group caps both values: + +```java +Map group = (Map) ((List) formatted.metadata().get("toolGroups")).getFirst(); +assertThat(group.get("commands")).asList().hasSize(20); +assertThat(group.get("paths")).asList().hasSize(20); +``` + +- [ ] **Step 3: Run privacy and formatter tests** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentEventRedactorTest,AgentSegmentFormatterTest test +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +Run: + +```bash +git add memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactorTest.java memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentToolTelemetry.java +git commit -m "test(agent): cover tool telemetry privacy and caps" +``` + +--- + +### Task 7: Document The RawData-Agent / RawData-ToolCall Boundary + +**Files:** +- Modify: `memind-integrations/claude-code/README.md` +- Modify: `memind-integrations/codex/README.md` +- Modify: `docs/superpowers/specs/2026-05-24-rawdata-agent-design.md` + +- [ ] **Step 1: Update Claude Code README** + +In the `rawdata-toolcall` relationship section, include this text: + +```markdown +`rawdata-agent` absorbs deterministic tool telemetry from the `rawdata-toolcall` design: duration, token counts, +content hashes, per-episode tool records, and per-tool success/failure stats. Claude Code still submits one canonical +`agent_timeline` per turn; it does not submit duplicate `tool_call` raw data. `rawdata-toolcall` remains the correct +entry point for pure tool-call logs that do not have user prompts, agent turns, or Stop boundaries. +``` + +- [ ] **Step 2: Update Codex README** + +Add the same boundary text to `memind-integrations/codex/README.md`. + +- [ ] **Step 3: Update design document relationship section** + +In `docs/superpowers/specs/2026-05-24-rawdata-agent-design.md`, refine the `rawdata-toolcall` relationship section to include: + +```markdown +`rawdata-toolcall` remains a separate raw data type for pure tool-call logs. `rawdata-agent` may reuse deterministic +tool telemetry ideas from `rawdata-toolcall`, but must not run the toolcall LLM extraction path by default and must not +double-ingest Claude Code or Codex tool activity. +``` + +- [ ] **Step 4: Commit** + +Run: + +```bash +git add memind-integrations/claude-code/README.md memind-integrations/codex/README.md docs/superpowers/specs/2026-05-24-rawdata-agent-design.md +git commit -m "docs(agent): clarify tool telemetry boundary" +``` + +--- + +### Task 8: Full Verification + +**Files:** +- No new files. + +- [ ] **Step 1: Run Python integration unit tests** + +Run: + +```bash +python3 -m unittest memind-integrations/claude-code/tests/test_agent_timeline.py memind-integrations/codex/tests/test_agent_timeline.py +``` + +Expected: PASS. + +- [ ] **Step 2: Run rawdata-agent Maven tests** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -am test +``` + +Expected: PASS. + +- [ ] **Step 3: Run formatting checks for touched Java module** + +Run: + +```bash +mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent spotless:check +``` + +Expected: PASS. + +- [ ] **Step 4: Inspect diff for accidental double ingestion** + +Run: + +```bash +git diff -- memind-integrations/claude-code/hooks/hooks.json memind-integrations/codex/hooks/hooks.json memind-integrations/claude-code/scripts memind-integrations/codex/scripts +``` + +Expected: no change that submits `tool_call` raw data from Claude Code or Codex. + +- [ ] **Step 5: Inspect rawdata-toolcall for unintended edits** + +Run: + +```bash +git diff -- memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-toolcall +``` + +Expected: empty diff unless documentation references were intentionally changed outside that module. + +- [ ] **Step 6: Commit verification-only fixes if formatting required changes** + +If formatting changes were applied by `spotless:apply`, commit them: + +```bash +git add memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent +git commit -m "style(agent): format tool telemetry changes" +``` + +If no formatting changes were made, do not create a verification commit. + +--- + +## Expected Outcome + +After this plan is implemented: + +- Claude Code and Codex tool events carry stable tool telemetry into `agent_timeline`. +- `rawdata-agent` episode segments expose file/tool-aware evidence through `toolRecords`, `toolStats`, and `toolGroups`. +- Deterministic tool memories become more useful without increasing LLM calls. +- `rawdata-toolcall` remains valuable for pure tool logs and is not duplicated by Claude Code/Codex. +- The new metadata creates a stronger foundation for a later PreToolUse file-history context feature. + +## Self-Review Notes + +- Spec coverage: covers deterministic fields, redacted `contentHash`, episode metadata, deterministic item enrichment, documentation boundary, and verification. +- Placeholder scan: no deferred implementation markers are used. +- Type consistency: hook payload emits `durationMs`, `inputTokens`, `outputTokens`, `contentHash`; Java `AgentEvent` uses the same names; segment metadata uses `toolRecords`, `toolStats`, and `toolGroups`. +- Scope check: PreToolUse file context and OpenAPI file queries are explicitly excluded to keep this implementation focused and independently testable. diff --git a/docs/superpowers/specs/2026-05-24-rawdata-agent-design.md b/docs/superpowers/specs/2026-05-24-rawdata-agent-design.md index 86de9a26..78da8003 100644 --- a/docs/superpowers/specs/2026-05-24-rawdata-agent-design.md +++ b/docs/superpowers/specs/2026-05-24-rawdata-agent-design.md @@ -1175,6 +1175,8 @@ rawdata-agent canonical path for complete agent work process timelines ``` +`rawdata-toolcall` remains a separate raw data type for pure tool-call logs. `rawdata-agent` may reuse deterministic tool telemetry ideas from `rawdata-toolcall`, but must not run the toolcall LLM extraction path by default and must not double-ingest Claude Code or Codex tool activity. + Long-term, `rawdata-toolcall` can internally adapt pure tool-call records into `agent_timeline` episodes to reuse the same item extraction logic. This avoids duplicate tool extraction prompts and inconsistent TOOL memories. ## Configuration diff --git a/memind-integrations/claude-code/README.md b/memind-integrations/claude-code/README.md index 36bc17d9..ce2dc2cc 100644 --- a/memind-integrations/claude-code/README.md +++ b/memind-integrations/claude-code/README.md @@ -534,9 +534,10 @@ memory, confirm the same `userId` and `agentId` are used for ingestion and retri - Arbitrary overlapping partial windows are adapter responsibility in v1. - File content capture is disabled by default. - `rawdata-toolcall` remains supported. -- If `rawdata-toolcall` and `rawdata-agent` ingest the same tool activity, v1 may create semantically overlapping - TOOL items. This is acceptable compatibility behavior; do not add cross-plugin suppression in v1. Users who - want one canonical coding-agent path should enable `rawdata-agent` for full agent timelines and keep - `rawdata-toolcall` for pure legacy tool-call logs. +- `rawdata-agent` absorbs deterministic tool telemetry from the `rawdata-toolcall` design: duration, token counts, + content hashes, per-episode tool records, and per-tool success/failure stats. Claude Code still submits one + canonical `agent_timeline` per turn; it does not submit duplicate `tool_call` raw data. `rawdata-toolcall` + remains the correct entry point for pure tool-call logs that do not have user prompts, agent turns, or Stop + boundaries. - Retrieval quality depends on existing extracted Memind items and insights. - The plugin does not start or configure the Memind server. diff --git a/memind-integrations/claude-code/scripts/lib/agent_timeline.py b/memind-integrations/claude-code/scripts/lib/agent_timeline.py index f7d9bcde..eefd36f5 100644 --- a/memind-integrations/claude-code/scripts/lib/agent_timeline.py +++ b/memind-integrations/claude-code/scripts/lib/agent_timeline.py @@ -134,6 +134,104 @@ def _json_text(value): return json.dumps(value, ensure_ascii=False, sort_keys=True) +def _number_value(*values): + for value in values: + if isinstance(value, bool): + continue + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str) and value.strip().isdigit(): + return int(value.strip()) + return None + + +def _nested_number(mapping, *path): + current = mapping + for key in path: + if not isinstance(current, dict): + return None + current = current.get(key) + return _number_value(current) + + +def _tool_telemetry(hook_input, tool_response): + usage = tool_response.get("usage") if isinstance(tool_response, dict) else {} + return { + "durationMs": _number_value( + hook_input.get("duration_ms"), + hook_input.get("durationMs"), + tool_response.get("duration_ms") if isinstance(tool_response, dict) else None, + tool_response.get("durationMs") if isinstance(tool_response, dict) else None, + _nested_number(tool_response, "metadata", "duration_ms"), + _nested_number(tool_response, "metadata", "durationMs"), + ), + "inputTokens": _number_value( + hook_input.get("input_tokens"), + hook_input.get("inputTokens"), + tool_response.get("input_tokens") if isinstance(tool_response, dict) else None, + tool_response.get("inputTokens") if isinstance(tool_response, dict) else None, + usage.get("input_tokens") if isinstance(usage, dict) else None, + usage.get("inputTokens") if isinstance(usage, dict) else None, + ), + "outputTokens": _number_value( + hook_input.get("output_tokens"), + hook_input.get("outputTokens"), + tool_response.get("output_tokens") if isinstance(tool_response, dict) else None, + tool_response.get("outputTokens") if isinstance(tool_response, dict) else None, + usage.get("output_tokens") if isinstance(usage, dict) else None, + usage.get("outputTokens") if isinstance(usage, dict) else None, + ), + } + + +VOLATILE_TOOL_OUTPUT_KEYS = { + "exit_code", + "exitCode", + "duration_ms", + "durationMs", + "input_tokens", + "inputTokens", + "output_tokens", + "outputTokens", + "usage", +} + + +def _semantic_tool_output(raw_tool_response): + if isinstance(raw_tool_response, list): + return [ + normalized + for normalized in (_semantic_tool_output(value) for value in raw_tool_response) + if normalized not in (None, {}, []) + ] + if not isinstance(raw_tool_response, dict): + return raw_tool_response + result = {} + for key, value in raw_tool_response.items(): + if key in VOLATILE_TOOL_OUTPUT_KEYS or value is None: + continue + normalized = _semantic_tool_output(value) + if normalized not in (None, {}, []): + result[key] = normalized + return result + + +def _content_hash(tool_name, normalized_input, normalized_output): + stable = json.dumps( + { + "toolName": tool_name or "", + "input": normalized_input or "", + "output": normalized_output or "", + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + return "sha256:" + hashlib.sha256(stable.encode("utf-8")).hexdigest() + + def _tool_tokens(tool_name): if not tool_name: return [] @@ -505,19 +603,25 @@ def normalize_hook_event(hook_input, seq, turn_id=None, turn_seq=None): else: event["status"] = "success" if hook_input.get("hook_event_name") == "PostToolUse" else "running" - if isinstance(raw_tool_response, dict): - output = { - key: value - for key, value in raw_tool_response.items() - if key not in {"exit_code", "exitCode"} and value is not None - } - else: - output = raw_tool_response + output = _semantic_tool_output(raw_tool_response) if output: redacted_output, kinds = _redact_value(output) event["output"] = _json_text(redacted_output) redaction_kinds.extend(kinds) + telemetry = _tool_telemetry(hook_input, tool_response) + if telemetry.get("durationMs") is not None: + event["durationMs"] = telemetry["durationMs"] + if telemetry.get("inputTokens") is not None: + event["inputTokens"] = telemetry["inputTokens"] + if telemetry.get("outputTokens") is not None: + event["outputTokens"] = telemetry["outputTokens"] + event["contentHash"] = _content_hash( + tool_name, + event.get("command") if event.get("command") is not None else event.get("input"), + event.get("output"), + ) + metadata = { "hookEventName": hook_input.get("hook_event_name"), "sessionId": session_id, diff --git a/memind-integrations/claude-code/tests/test_agent_timeline.py b/memind-integrations/claude-code/tests/test_agent_timeline.py index 759a384d..315b1447 100644 --- a/memind-integrations/claude-code/tests/test_agent_timeline.py +++ b/memind-integrations/claude-code/tests/test_agent_timeline.py @@ -13,7 +13,12 @@ # import json +import sys import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) from scripts.lib.agent_timeline import ( build_timeline_payload, @@ -77,6 +82,101 @@ def test_normalizes_non_test_bash_to_command_event(self): self.assertEqual(event["status"], "success") self.assertEqual(event["metadata"]["normalizationVersion"], 1) + def test_normalizes_tool_telemetry_and_content_hash(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "Bash", + "tool_input": {"command": "npm test payment"}, + "tool_response": { + "exit_code": 0, + "stdout": "passed", + "duration_ms": 1234, + "usage": {"input_tokens": 11, "output_tokens": 22}, + }, + "timestamp": "2026-05-24T10:00:00Z", + }, + seq=1, + ) + + self.assertEqual(event["durationMs"], 1234) + self.assertEqual(event["inputTokens"], 11) + self.assertEqual(event["outputTokens"], 22) + self.assertTrue(event["contentHash"].startswith("sha256:")) + self.assertEqual(len(event["contentHash"]), len("sha256:") + 64) + self.assertEqual(event["output"], '{"stdout": "passed"}') + self.assertEqual(event["metadata"]["normalizationVersion"], 1) + + def test_content_hash_uses_redacted_payload(self): + first = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "CustomTool", + "tool_input": {"token": "Bearer first-secret-value"}, + "tool_response": {"result": "ok"}, + "timestamp": "2026-05-24T10:00:00Z", + }, + seq=1, + ) + second = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "CustomTool", + "tool_input": {"token": "Bearer second-secret-value"}, + "tool_response": {"result": "ok"}, + "timestamp": "2026-05-24T10:00:01Z", + }, + seq=2, + ) + + self.assertEqual(first["contentHash"], second["contentHash"]) + self.assertIn("[REDACTED:bearer_token]", first["input"]) + self.assertIn("[REDACTED:bearer_token]", second["input"]) + + def test_content_hash_ignores_volatile_telemetry_fields(self): + first = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "Bash", + "tool_input": {"command": "npm test payment"}, + "tool_response": { + "exit_code": 0, + "stdout": "passed", + "duration_ms": 100, + "metadata": {"duration_ms": 100}, + "usage": {"input_tokens": 11, "output_tokens": 22}, + }, + "timestamp": "2026-05-24T10:00:00Z", + }, + seq=1, + ) + second = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "Bash", + "tool_input": {"command": "npm test payment"}, + "tool_response": { + "exit_code": 0, + "stdout": "passed", + "duration_ms": 999, + "metadata": {"duration_ms": 999}, + "usage": {"input_tokens": 100, "output_tokens": 200}, + }, + "timestamp": "2026-05-24T10:00:01Z", + }, + seq=2, + ) + + self.assertEqual(first["contentHash"], second["contentHash"]) + self.assertNotIn("duration_ms", first.get("output", "")) + self.assertNotIn("metadata", first.get("output", "")) + self.assertNotIn("usage", first.get("output", "")) + def test_normalizes_file_read_tool_with_path(self): event = normalize_hook_event( { diff --git a/memind-integrations/claude-code/tests/test_context_compiler.py b/memind-integrations/claude-code/tests/test_context_compiler.py index 0579ec18..a4eb5614 100644 --- a/memind-integrations/claude-code/tests/test_context_compiler.py +++ b/memind-integrations/claude-code/tests/test_context_compiler.py @@ -1,3 +1,17 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + import sys import unittest from pathlib import Path diff --git a/memind-integrations/codex/README.md b/memind-integrations/codex/README.md index 2ad90e46..ab18c79b 100644 --- a/memind-integrations/codex/README.md +++ b/memind-integrations/codex/README.md @@ -498,8 +498,8 @@ curl -fsSL http://127.0.0.1:8366/open/v1/health - Arbitrary overlapping partial windows are adapter responsibility in v1. - File content capture is disabled by default. - `rawdata-toolcall` remains supported. -- If `rawdata-toolcall` and `rawdata-agent` ingest the same tool activity, v1 may create semantically overlapping - TOOL items. This is acceptable compatibility behavior; do not add cross-plugin suppression in v1. Users who - want one canonical coding-agent path should enable `rawdata-agent` for full agent timelines and keep - `rawdata-toolcall` for pure legacy tool-call logs. +- `rawdata-agent` absorbs deterministic tool telemetry from the `rawdata-toolcall` design: duration, token counts, + content hashes, per-episode tool records, and per-tool success/failure stats. Codex still submits one canonical + `agent_timeline` per turn; it does not submit duplicate `tool_call` raw data. `rawdata-toolcall` remains the + correct entry point for pure tool-call logs that do not have user prompts, agent turns, or Stop boundaries. - Retrieval quality depends on existing extracted Memind items and insights. diff --git a/memind-integrations/codex/scripts/lib/agent_timeline.py b/memind-integrations/codex/scripts/lib/agent_timeline.py index df40f22e..ff930569 100644 --- a/memind-integrations/codex/scripts/lib/agent_timeline.py +++ b/memind-integrations/codex/scripts/lib/agent_timeline.py @@ -134,6 +134,104 @@ def _json_text(value): return json.dumps(value, ensure_ascii=False, sort_keys=True) +def _number_value(*values): + for value in values: + if isinstance(value, bool): + continue + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str) and value.strip().isdigit(): + return int(value.strip()) + return None + + +def _nested_number(mapping, *path): + current = mapping + for key in path: + if not isinstance(current, dict): + return None + current = current.get(key) + return _number_value(current) + + +def _tool_telemetry(hook_input, tool_response): + usage = tool_response.get("usage") if isinstance(tool_response, dict) else {} + return { + "durationMs": _number_value( + hook_input.get("duration_ms"), + hook_input.get("durationMs"), + tool_response.get("duration_ms") if isinstance(tool_response, dict) else None, + tool_response.get("durationMs") if isinstance(tool_response, dict) else None, + _nested_number(tool_response, "metadata", "duration_ms"), + _nested_number(tool_response, "metadata", "durationMs"), + ), + "inputTokens": _number_value( + hook_input.get("input_tokens"), + hook_input.get("inputTokens"), + tool_response.get("input_tokens") if isinstance(tool_response, dict) else None, + tool_response.get("inputTokens") if isinstance(tool_response, dict) else None, + usage.get("input_tokens") if isinstance(usage, dict) else None, + usage.get("inputTokens") if isinstance(usage, dict) else None, + ), + "outputTokens": _number_value( + hook_input.get("output_tokens"), + hook_input.get("outputTokens"), + tool_response.get("output_tokens") if isinstance(tool_response, dict) else None, + tool_response.get("outputTokens") if isinstance(tool_response, dict) else None, + usage.get("output_tokens") if isinstance(usage, dict) else None, + usage.get("outputTokens") if isinstance(usage, dict) else None, + ), + } + + +VOLATILE_TOOL_OUTPUT_KEYS = { + "exit_code", + "exitCode", + "duration_ms", + "durationMs", + "input_tokens", + "inputTokens", + "output_tokens", + "outputTokens", + "usage", +} + + +def _semantic_tool_output(raw_tool_response): + if isinstance(raw_tool_response, list): + return [ + normalized + for normalized in (_semantic_tool_output(value) for value in raw_tool_response) + if normalized not in (None, {}, []) + ] + if not isinstance(raw_tool_response, dict): + return raw_tool_response + result = {} + for key, value in raw_tool_response.items(): + if key in VOLATILE_TOOL_OUTPUT_KEYS or value is None: + continue + normalized = _semantic_tool_output(value) + if normalized not in (None, {}, []): + result[key] = normalized + return result + + +def _content_hash(tool_name, normalized_input, normalized_output): + stable = json.dumps( + { + "toolName": tool_name or "", + "input": normalized_input or "", + "output": normalized_output or "", + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + return "sha256:" + hashlib.sha256(stable.encode("utf-8")).hexdigest() + + def _tool_tokens(tool_name): if not tool_name: return [] @@ -451,19 +549,25 @@ def normalize_hook_event(hook_input, seq, turn_id=None, turn_seq=None): else: event["status"] = "success" if hook_input.get("hook_event_name") == "PostToolUse" else "running" - if isinstance(raw_tool_response, dict): - output = { - key: value - for key, value in raw_tool_response.items() - if key not in {"exit_code", "exitCode"} and value is not None - } - else: - output = raw_tool_response + output = _semantic_tool_output(raw_tool_response) if output: redacted_output, kinds = _redact_value(output) event["output"] = _json_text(redacted_output) redaction_kinds.extend(kinds) + telemetry = _tool_telemetry(hook_input, tool_response) + if telemetry.get("durationMs") is not None: + event["durationMs"] = telemetry["durationMs"] + if telemetry.get("inputTokens") is not None: + event["inputTokens"] = telemetry["inputTokens"] + if telemetry.get("outputTokens") is not None: + event["outputTokens"] = telemetry["outputTokens"] + event["contentHash"] = _content_hash( + tool_name, + event.get("command") if event.get("command") is not None else event.get("input"), + event.get("output"), + ) + metadata = { "hookEventName": hook_input.get("hook_event_name"), "sessionId": session_id, diff --git a/memind-integrations/codex/tests/test_agent_timeline.py b/memind-integrations/codex/tests/test_agent_timeline.py index a7c290dd..e839daef 100644 --- a/memind-integrations/codex/tests/test_agent_timeline.py +++ b/memind-integrations/codex/tests/test_agent_timeline.py @@ -13,7 +13,12 @@ # import json +import sys import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) from scripts.lib.agent_timeline import ( build_timeline_payload, @@ -77,6 +82,107 @@ def test_normalizes_non_test_bash_to_command_event(self): self.assertEqual(event["status"], "success") self.assertEqual(event["metadata"]["normalizationVersion"], 1) + def test_normalizes_tool_telemetry_and_content_hash(self): + event = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "Bash", + "tool_input": {"command": "npm test payment"}, + "tool_response": { + "exit_code": 0, + "stdout": "passed", + "duration_ms": 1234, + "usage": {"input_tokens": 11, "output_tokens": 22}, + }, + "timestamp": "2026-05-24T10:00:00Z", + "source_client": "codex", + }, + seq=1, + ) + + self.assertEqual(event["durationMs"], 1234) + self.assertEqual(event["inputTokens"], 11) + self.assertEqual(event["outputTokens"], 22) + self.assertTrue(event["contentHash"].startswith("sha256:")) + self.assertEqual(len(event["contentHash"]), len("sha256:") + 64) + self.assertEqual(event["output"], '{"stdout": "passed"}') + self.assertEqual(event["metadata"]["normalizationVersion"], 1) + self.assertEqual(event["metadata"]["sourceClient"], "codex") + + def test_content_hash_uses_redacted_payload(self): + first = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "CustomTool", + "tool_input": {"token": "Bearer first-secret-value"}, + "tool_response": {"result": "ok"}, + "timestamp": "2026-05-24T10:00:00Z", + "source_client": "codex", + }, + seq=1, + ) + second = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "CustomTool", + "tool_input": {"token": "Bearer second-secret-value"}, + "tool_response": {"result": "ok"}, + "timestamp": "2026-05-24T10:00:01Z", + "source_client": "codex", + }, + seq=2, + ) + + self.assertEqual(first["contentHash"], second["contentHash"]) + self.assertIn("[REDACTED:bearer_token]", first["input"]) + self.assertIn("[REDACTED:bearer_token]", second["input"]) + + def test_content_hash_ignores_volatile_telemetry_fields(self): + first = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "Bash", + "tool_input": {"command": "npm test payment"}, + "tool_response": { + "exit_code": 0, + "stdout": "passed", + "duration_ms": 100, + "metadata": {"duration_ms": 100}, + "usage": {"input_tokens": 11, "output_tokens": 22}, + }, + "timestamp": "2026-05-24T10:00:00Z", + "source_client": "codex", + }, + seq=1, + ) + second = normalize_hook_event( + { + "hook_event_name": "PostToolUse", + "session_id": "s", + "tool_name": "Bash", + "tool_input": {"command": "npm test payment"}, + "tool_response": { + "exit_code": 0, + "stdout": "passed", + "duration_ms": 999, + "metadata": {"duration_ms": 999}, + "usage": {"input_tokens": 100, "output_tokens": 200}, + }, + "timestamp": "2026-05-24T10:00:01Z", + "source_client": "codex", + }, + seq=2, + ) + + self.assertEqual(first["contentHash"], second["contentHash"]) + self.assertNotIn("duration_ms", first.get("output", "")) + self.assertNotIn("metadata", first.get("output", "")) + self.assertNotIn("usage", first.get("output", "")) + def test_normalizes_file_read_tool_with_path(self): event = normalize_hook_event( { diff --git a/memind-integrations/codex/tests/test_context_compiler.py b/memind-integrations/codex/tests/test_context_compiler.py index 647ec682..9be0cbb6 100644 --- a/memind-integrations/codex/tests/test_context_compiler.py +++ b/memind-integrations/codex/tests/test_context_compiler.py @@ -1,3 +1,17 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + import sys import unittest from pathlib import Path diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java index ea4fb543..7963e025 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java @@ -99,6 +99,7 @@ private Map metadata(AgentTimelineContent timeline, AgentEpisode metadata.put("eventIds", episode.eventIds()); metadata.put("commandEvents", commandEventMetadata(episode.commandEvents())); metadata.put("fileEvents", fileEventMetadata(episode.fileReferences())); + metadata.putAll(AgentToolTelemetry.metadata(episode.events())); if (episode.startTime() != null) { metadata.put("windowStart", episode.startTime()); } diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentToolTelemetry.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentToolTelemetry.java new file mode 100644 index 00000000..1ee44ee2 --- /dev/null +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentToolTelemetry.java @@ -0,0 +1,205 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.openmemind.ai.memory.plugin.rawdata.agent.chunk; + +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEvent; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventStatus; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +final class AgentToolTelemetry { + + private static final int MAX_TOOL_RECORDS = 40; + private static final int MAX_TOOL_STATS = 20; + private static final int MAX_TOOL_GROUPS = 20; + private static final int MAX_GROUP_VALUES = 20; + private static final int MAX_OUTPUT_PREVIEW_CHARS = 240; + + private AgentToolTelemetry() {} + + static Map metadata(List events) { + List toolEvents = + events == null + ? List.of() + : events.stream().filter(AgentToolTelemetry::hasToolEvidence).toList(); + if (toolEvents.isEmpty()) { + return Map.of(); + } + + var metadata = new LinkedHashMap(); + metadata.put("toolRecords", toolRecords(toolEvents)); + metadata.put("toolStats", toolStats(toolEvents)); + metadata.put("toolGroups", toolGroups(toolEvents)); + return Map.copyOf(metadata); + } + + private static boolean hasToolEvidence(AgentEvent event) { + return event != null && hasText(event.toolName()); + } + + private static List> toolRecords(List events) { + var records = new ArrayList>(); + events.stream().limit(MAX_TOOL_RECORDS).forEach(event -> records.add(toolRecord(event))); + return List.copyOf(records); + } + + private static Map toolRecord(AgentEvent event) { + var record = new LinkedHashMap(); + put(record, "eventId", event.eventId()); + put(record, "seq", event.seq()); + put(record, "toolName", event.toolName()); + put(record, "kind", event.kind() == null ? null : event.kind().wireValue()); + put(record, "status", event.status() == null ? null : event.status().wireValue()); + put(record, "durationMs", event.durationMs()); + put(record, "inputTokens", event.inputTokens()); + put(record, "outputTokens", event.outputTokens()); + put(record, "contentHash", event.contentHash()); + put(record, "path", event.path()); + put(record, "operation", event.operation()); + put(record, "command", event.command()); + put(record, "outputPreview", concise(event.output())); + return Map.copyOf(record); + } + + private static Map> toolStats(List events) { + var grouped = groupByToolName(events); + var stats = new LinkedHashMap>(); + grouped.entrySet().stream() + .limit(MAX_TOOL_STATS) + .forEach( + groupEntry -> { + String toolName = groupEntry.getKey(); + List toolEvents = groupEntry.getValue(); + int success = countStatus(toolEvents, AgentEventStatus.SUCCESS); + int failed = countStatus(toolEvents, AgentEventStatus.FAILED); + var durations = + toolEvents.stream() + .map(AgentEvent::durationMs) + .filter(value -> value != null) + .mapToLong(Long::longValue) + .summaryStatistics(); + var stat = new LinkedHashMap(); + stat.put("callCount", toolEvents.size()); + stat.put("successCount", success); + stat.put("failCount", failed); + if (durations.getCount() > 0) { + stat.put("avgDurationMs", Math.round(durations.getAverage())); + } + sum(toolEvents, AgentEvent::inputTokens) + .ifPresent(value -> stat.put("inputTokens", value)); + sum(toolEvents, AgentEvent::outputTokens) + .ifPresent(value -> stat.put("outputTokens", value)); + stats.put(toolName, Map.copyOf(stat)); + }); + return Map.copyOf(stats); + } + + private static List> toolGroups(List events) { + var grouped = groupByToolName(events); + var groups = new ArrayList>(); + grouped.entrySet().stream() + .limit(MAX_TOOL_GROUPS) + .forEach( + groupEntry -> { + String toolName = groupEntry.getKey(); + List toolEvents = groupEntry.getValue(); + var group = new LinkedHashMap(); + group.put("toolName", toolName); + group.put("callCount", toolEvents.size()); + group.put( + "successCount", + countStatus(toolEvents, AgentEventStatus.SUCCESS)); + group.put( + "failCount", countStatus(toolEvents, AgentEventStatus.FAILED)); + putCollection( + group, + "commands", + distinctValues(toolEvents, AgentEvent::command)); + putCollection( + group, "paths", distinctValues(toolEvents, AgentEvent::path)); + groups.add(Map.copyOf(group)); + }); + return List.copyOf(groups); + } + + private static LinkedHashMap> groupByToolName( + List events) { + var grouped = new LinkedHashMap>(); + events.forEach( + event -> + grouped.computeIfAbsent(event.toolName(), key -> new ArrayList<>()) + .add(event)); + return grouped; + } + + private static List distinctValues( + List events, Function getter) { + var values = new LinkedHashSet(); + for (AgentEvent event : events) { + String value = getter.apply(event); + if (hasText(value)) { + values.add(value); + } + if (values.size() >= MAX_GROUP_VALUES) { + break; + } + } + return List.copyOf(values); + } + + private static int countStatus(List events, AgentEventStatus status) { + return (int) events.stream().filter(event -> event.status() == status).count(); + } + + private static Optional sum( + List events, Function getter) { + var values = events.stream().map(getter).filter(value -> value != null).toList(); + if (values.isEmpty()) { + return Optional.empty(); + } + return Optional.of(values.stream().mapToInt(Integer::intValue).sum()); + } + + private static void put(Map target, String key, Object value) { + if (value != null && (!(value instanceof String text) || !text.isBlank())) { + target.put(key, value); + } + } + + private static void putCollection(Map target, String key, List values) { + if (values != null && !values.isEmpty()) { + target.put(key, values); + } + } + + private static String concise(String value) { + if (!hasText(value)) { + return null; + } + String normalized = value.replaceAll("\\s+", " ").trim(); + if (normalized.length() <= MAX_OUTPUT_PREVIEW_CHARS) { + return normalized; + } + return normalized.substring(0, MAX_OUTPUT_PREVIEW_CHARS); + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java index 4ae2324a..9a2a5b57 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java @@ -275,6 +275,9 @@ private String canonicalEvent(AgentEvent event) { normalized(event.output()), event.status() == null ? "" : event.status().wireValue(), normalized(event.durationMs()), + normalized(event.inputTokens()), + normalized(event.outputTokens()), + normalized(event.contentHash()), normalized(event.path()), normalized(event.operation()), normalized(event.command()), diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java index b5c75bc7..f34fb34e 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java @@ -146,6 +146,9 @@ private Map baseMetadata(EpisodeMetadata episode) { copy(episode.raw(), metadata, "projectName"); copy(episode.raw(), metadata, "projectRootHash"); copy(episode.raw(), metadata, "gitBranch"); + copy(episode.raw(), metadata, "toolStats"); + copy(episode.raw(), metadata, "toolRecords"); + copy(episode.raw(), metadata, "toolGroups"); metadata.put("files", episode.files()); metadata.put("commands", episode.commands()); metadata.put("toolNames", episode.toolNames()); @@ -180,8 +183,13 @@ private String toolContent( int successCount, int failCount) { if (command != null && !episode.files().isEmpty()) { - return "Use %s to validate changes touching %s." - .formatted(command, String.join(", ", episode.files())); + String fileList = String.join(", ", episode.files()); + if (failCount > 0 || successCount > 0) { + return "Use %s to validate changes touching %s; it failed %s and passed %s in this agent episode." + .formatted( + command, fileList, countWord(failCount), countWord(successCount)); + } + return "Use %s to validate changes touching %s.".formatted(command, fileList); } if (command != null) { return "%s command %s failed %s and passed %s in episode %s." diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEvent.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEvent.java index 71b3aa65..fa241ab0 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEvent.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEvent.java @@ -30,6 +30,9 @@ public record AgentEvent( String output, AgentEventStatus status, Long durationMs, + Integer inputTokens, + Integer outputTokens, + String contentHash, String path, String operation, String command, diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactor.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactor.java index b1da54d6..9be8352b 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactor.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactor.java @@ -82,6 +82,9 @@ public AgentEvent redact(AgentEvent event) { output, event.status(), event.durationMs(), + event.inputTokens(), + event.outputTokens(), + event.contentHash(), event.path(), event.operation(), command, diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java index 726de771..06451d35 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java @@ -587,6 +587,9 @@ private static AgentEvent eventWithMetadata( base.output(), base.status(), base.durationMs(), + base.inputTokens(), + base.outputTokens(), + base.contentHash(), base.path(), base.operation(), base.command(), @@ -621,6 +624,9 @@ private static AgentEvent eventWithTurnMetadata( base.output(), base.status(), base.durationMs(), + base.inputTokens(), + base.outputTokens(), + base.contentHash(), base.path(), base.operation(), base.command(), diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeTestSupport.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeTestSupport.java index 620e5b95..6a88502c 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeTestSupport.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeTestSupport.java @@ -134,6 +134,9 @@ static AgentEvent event( output, status, 10L, + null, + null, + null, path, operation, command, diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java index 8f816f7d..327b718f 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java @@ -15,7 +15,14 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.openmemind.ai.memory.plugin.rawdata.agent.content.AgentTimelineContent; import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEpisode; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEvent; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventKind; +import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventStatus; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -107,7 +114,160 @@ void shouldFormatEpisodeTextAndMetadataDeterministically() { "src/payment/calc.ts", "operation", "edit")); + assertThat(formatted.metadata().get("toolRecords")) + .asList() + .contains( + Map.of( + "eventId", + "e2", + "seq", + 2, + "toolName", + "Bash", + "kind", + "command", + "status", + "failed", + "durationMs", + 10L, + "command", + "npm test payment", + "outputPreview", + "rounding mismatch")); + assertThat(formatted.metadata().get("toolStats")) + .isEqualTo( + Map.of( + "Bash", + Map.of( + "callCount", + 2, + "successCount", + 1, + "failCount", + 1, + "avgDurationMs", + 10L), + "Edit", + Map.of( + "callCount", + 1, + "successCount", + 1, + "failCount", + 0, + "avgDurationMs", + 10L))); + assertThat(formatted.metadata().get("toolGroups")) + .asList() + .contains( + Map.of( + "toolName", + "Bash", + "callCount", + 2, + "successCount", + 1, + "failCount", + 1, + "commands", + List.of("npm test payment"))); assertThat(formatted.metadata()).doesNotContainKey("projectRootRaw"); assertThat(formatted.metadata()).containsKey("projectRootHash"); } + + @Test + void shouldCapToolRecordMetadata() { + var events = new ArrayList(); + events.add( + AgentEpisodeTestSupport.event( + "prompt", + 1, + AgentEventKind.USER_PROMPT, + "2026-05-24T10:00:00Z", + "Run many commands", + null, + null, + AgentEventStatus.SUCCESS, + null, + null, + null, + null)); + for (int i = 0; i < 45; i++) { + events.add(commandEvent("tool-" + i, i + 2, "Bash", "npm test module-" + i, null)); + } + + AgentSegmentFormatter.FormattedSegment formatted = + formatSingleEpisode(AgentEpisodeTestSupport.paymentTimeline(events)); + + assertThat(formatted.metadata().get("toolRecords")).asList().hasSize(40); + } + + @Test + void shouldCapToolStatsGroupsAndGroupValues() { + var events = new ArrayList(); + events.add( + AgentEpisodeTestSupport.event( + "prompt", + 1, + AgentEventKind.USER_PROMPT, + "2026-05-24T10:00:00Z", + "Run many tools", + null, + null, + AgentEventStatus.SUCCESS, + null, + null, + null, + null)); + for (int i = 0; i < 25; i++) { + events.add( + commandEvent( + "same-tool-command-" + i, + i + 2, + "Bash", + "npm test module-" + i, + "src/module-" + i + ".ts")); + } + for (int i = 0; i < 25; i++) { + events.add(commandEvent("distinct-tool-" + i, i + 40, "Tool" + i, "tool " + i, null)); + } + + AgentSegmentFormatter.FormattedSegment formatted = + formatSingleEpisode(AgentEpisodeTestSupport.paymentTimeline(events)); + + assertThat(((Map) formatted.metadata().get("toolStats"))).hasSize(20); + assertThat(formatted.metadata().get("toolGroups")).asList().hasSize(20); + Map group = (Map) ((List) formatted.metadata().get("toolGroups")).getFirst(); + assertThat(group.get("commands")).asList().hasSize(20); + assertThat(group.get("paths")).asList().hasSize(20); + } + + private static AgentSegmentFormatter.FormattedSegment formatSingleEpisode( + AgentTimelineContent timeline) { + AgentEpisode episode = new AgentEpisodeAssembler().assemble(timeline).getFirst(); + return new AgentSegmentFormatter().format(timeline, episode); + } + + private static AgentEvent commandEvent( + String id, int seq, String toolName, String command, String path) { + return new AgentEvent( + id, + seq, + AgentEventKind.COMMAND, + Instant.parse("2026-05-24T10:00:00Z").plusSeconds(seq), + null, + toolName, + null, + "passed", + AgentEventStatus.SUCCESS, + 10L, + null, + null, + "sha256:%064d".formatted(seq), + path, + "run", + command, + 0, + Map.of()); + } } diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentTimelineChunkerTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentTimelineChunkerTest.java index db7e6d4d..cd0214f2 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentTimelineChunkerTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentTimelineChunkerTest.java @@ -47,6 +47,9 @@ void shouldRedactAssembleAndFormatAgentTimelineSegments() { 10L, null, null, + null, + null, + null, "npm test payment", 1, Map.of()), diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java index ca9684bf..733c76fa 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java @@ -55,6 +55,9 @@ void contentShouldExposeDeterministicIdentityAndReadableTimelineText() { 1200L, null, null, + null, + null, + null, "npm test payment", 1, Map.of()), @@ -73,6 +76,9 @@ void contentShouldExposeDeterministicIdentityAndReadableTimelineText() { null, null, null, + null, + null, + null, Map.of())); AgentTimelineContent content = @@ -127,6 +133,9 @@ void jacksonRoundTripShouldPreserveSubtypeAndUserPromptText() throws Exception { null, null, null, + null, + null, + null, Map.of()))); String json = OBJECT_MAPPER.writeValueAsString(content); @@ -171,6 +180,110 @@ void jacksonShouldPreserveAgentTurnAndEventIdFields() throws Exception { assertThat(timeline.events()).extracting(AgentEvent::eventId).containsExactly("event-new"); } + @Test + void jacksonShouldPreserveToolTelemetryFields() throws Exception { + String json = + """ + { + "type": "agent_timeline", + "sourceClient": "claude-code", + "sessionId": "session-1", + "agentTurnId": "turn-1", + "timelineId": "timeline-1", + "events": [ + { + "eventId": "event-tool", + "seq": 1, + "kind": "command", + "toolName": "Bash", + "command": "npm test payment", + "status": "success", + "durationMs": 1234, + "inputTokens": 11, + "outputTokens": 22, + "contentHash": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + } + ] + } + """; + + RawContent decoded = OBJECT_MAPPER.readValue(json, RawContent.class); + + AgentEvent event = ((AgentTimelineContent) decoded).events().getFirst(); + assertThat(event.durationMs()).isEqualTo(1234L); + assertThat(event.inputTokens()).isEqualTo(11); + assertThat(event.outputTokens()).isEqualTo(22); + assertThat(event.contentHash()) + .isEqualTo( + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + } + + @Test + void contentIdShouldIncludeTypedToolTelemetryFields() { + AgentProject project = new AgentProject("payment-service", "/repo/payment", null, Map.of()); + AgentEvent first = + new AgentEvent( + "e1", + 1, + AgentEventKind.COMMAND, + Instant.parse("2026-05-24T10:00:00Z"), + null, + "Bash", + null, + "passed", + AgentEventStatus.SUCCESS, + 1234L, + 11, + 22, + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + null, + "run", + "npm test payment", + 0, + Map.of()); + AgentEvent second = + new AgentEvent( + "e1", + 1, + AgentEventKind.COMMAND, + Instant.parse("2026-05-24T10:00:00Z"), + null, + "Bash", + null, + "passed", + AgentEventStatus.SUCCESS, + 1234L, + 11, + 23, + "sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", + null, + "run", + "npm test payment", + 0, + Map.of()); + + AgentTimelineContent firstContent = + new AgentTimelineContent( + "claude-code", + "1.0", + "session-123", + "turn-1", + "timeline-1", + project, + List.of(first)); + AgentTimelineContent secondContent = + new AgentTimelineContent( + "claude-code", + "1.0", + "session-123", + "turn-1", + "timeline-1", + project, + List.of(second)); + + assertThat(firstContent.getContentId()).isNotEqualTo(secondContent.getContentId()); + } + @Test void eventKindShouldParseLifecycleWireValues() { assertThat(AgentEventKind.fromWireValue("notification")) @@ -206,6 +319,9 @@ void contentStringShouldIncludeLifecycleEventEvidence() { AgentEventStatus.FAILED, null, null, + null, + null, + null, "blocked", null, null, @@ -225,6 +341,9 @@ void contentStringShouldIncludeLifecycleEventEvidence() { null, null, null, + null, + null, + null, Map.of()), new AgentEvent( "compact-1", @@ -238,6 +357,9 @@ void contentStringShouldIncludeLifecycleEventEvidence() { AgentEventStatus.SUCCESS, null, null, + null, + null, + null, "manual", null, null, diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/integration/AgentExtractionPipelineIntegrationTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/integration/AgentExtractionPipelineIntegrationTest.java index 5efdb851..d5868b39 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/integration/AgentExtractionPipelineIntegrationTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/integration/AgentExtractionPipelineIntegrationTest.java @@ -492,6 +492,9 @@ private static AgentEvent event( output, status, 10L, + null, + null, + null, path, operation, command, diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java index 38bb6dd2..6ba4bfec 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java @@ -56,8 +56,15 @@ void shouldExtractDeterministicToolAndResolutionFromSuccessfulEpisode() { .containsEntry("projectName", "payments-api"); assertThat(entry.metadata()) .containsEntry("command", "npm test payment"); - assertThat(entry.metadata()).containsEntry("successCount", 1); - assertThat(entry.metadata()).containsEntry("failCount", 1); + assertThat(entry.metadata()) + .containsKeys("toolStats", "toolRecords", "toolGroups") + .containsEntry("successCount", 1) + .containsEntry("failCount", 1); + assertThat(entry.content()) + .contains("npm test payment") + .contains("failed once") + .contains("passed once") + .contains("src/payment/calc.ts"); assertThat(entry.graphHints().entities()) .extracting(ExtractedGraphHints.ExtractedEntityHint::name) .contains("Bash", "npm test payment", "src/payment/calc.ts"); @@ -277,8 +284,63 @@ private static ParsedSegment successfulEpisode() { "rounding mismatch"), commandEvent( "e4", 4, "npm test payment", "success", "passed"))), + Map.entry("fileEvents", List.of(fileEvent("e3", 3, "src/payment/calc.ts"))), + Map.entry( + "toolStats", + Map.of( + "Bash", + Map.of( + "callCount", + 2, + "successCount", + 1, + "failCount", + 1, + "avgDurationMs", + 10L))), + Map.entry( + "toolRecords", + List.of( + Map.of( + "eventId", + "e2", + "seq", + 2, + "toolName", + "Bash", + "status", + "failed", + "command", + "npm test payment", + "outputPreview", + "rounding mismatch"), + Map.of( + "eventId", + "e4", + "seq", + 4, + "toolName", + "Bash", + "status", + "success", + "command", + "npm test payment", + "outputPreview", + "passed"))), Map.entry( - "fileEvents", List.of(fileEvent("e3", 3, "src/payment/calc.ts"))))); + "toolGroups", + List.of( + Map.of( + "toolName", + "Bash", + "callCount", + 2, + "successCount", + 1, + "failCount", + 1, + "commands", + List.of("npm test payment")))))); } private static ParsedSegment failedEpisode() { diff --git a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactorTest.java b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactorTest.java index a2a00d2c..8870b15e 100644 --- a/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactorTest.java +++ b/memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactorTest.java @@ -53,6 +53,9 @@ void shouldRedactSecretsFromInputOutputAndMetadata() { 12L, null, null, + null, + null, + null, "deploy", 0, Map.of("existing", "value")); @@ -83,6 +86,9 @@ void shouldDropFileContentForSensitivePathsByDefault() { "secret file body", AgentEventStatus.SUCCESS, 12L, + null, + null, + null, "/repo/.env", "read", null, @@ -115,6 +121,9 @@ void shouldKeepFileContentWhenCaptureIsAllowedAndPathIsAllowed() { "DATABASE_URL=postgres://u:p@example/db", AgentEventStatus.SUCCESS, 12L, + null, + null, + null, "/repo/fixtures/.env", "read", null, @@ -130,6 +139,38 @@ void shouldKeepFileContentWhenCaptureIsAllowedAndPathIsAllowed() { .containsExactly("database_url"); } + @Test + void shouldPreserveToolTelemetryWhenRedactingText() { + AgentEvent event = + new AgentEvent( + "e1", + 1, + AgentEventKind.COMMAND, + Instant.parse("2026-05-24T10:00:00Z"), + null, + "Bash", + null, + "Bearer secret-token-value", + AgentEventStatus.SUCCESS, + 1234L, + 11, + 22, + "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + null, + null, + "npm test payment", + 0, + Map.of()); + + AgentEvent redacted = new AgentEventRedactor().redact(event); + + assertThat(redacted.durationMs()).isEqualTo(1234L); + assertThat(redacted.inputTokens()).isEqualTo(11); + assertThat(redacted.outputTokens()).isEqualTo(22); + assertThat(redacted.contentHash()).isEqualTo(event.contentHash()); + assertThat(redacted.output()).contains("[REDACTED:bearer_token]"); + } + private static AgentEvent commandWithOutput(String command, String output) { return new AgentEvent( "e1", @@ -144,6 +185,9 @@ private static AgentEvent commandWithOutput(String command, String output) { 42L, null, null, + null, + null, + null, command, 0, Map.of()); From b4001cfc72029f3d99ff1f252816e39d1c492d4f Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 14:53:12 +0800 Subject: [PATCH 39/54] feat(agent): configure pre-tool context --- .../claude-code/hooks/hooks.json | 3 +-- .../claude-code/scripts/lib/config.py | 10 +++++++ memind-integrations/claude-code/settings.json | 5 ++++ .../claude-code/tests/test_config.py | 27 +++++++++++++++++++ .../claude-code/tests/test_manifest.py | 4 ++- .../codex/scripts/lib/config.py | 10 +++++++ memind-integrations/codex/settings.json | 5 ++++ .../codex/tests/test_config.py | 27 ++++++++++++++++++- .../codex/tests/test_manifest.py | 1 + 9 files changed, 88 insertions(+), 4 deletions(-) diff --git a/memind-integrations/claude-code/hooks/hooks.json b/memind-integrations/claude-code/hooks/hooks.json index 995ebe2a..90c5538a 100644 --- a/memind-integrations/claude-code/hooks/hooks.json +++ b/memind-integrations/claude-code/hooks/hooks.json @@ -28,8 +28,7 @@ { "type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/pre_tool_use.py\"", - "timeout": 5, - "async": true + "timeout": 5 } ] } diff --git a/memind-integrations/claude-code/scripts/lib/config.py b/memind-integrations/claude-code/scripts/lib/config.py index b93c7b76..bf0b3d1e 100644 --- a/memind-integrations/claude-code/scripts/lib/config.py +++ b/memind-integrations/claude-code/scripts/lib/config.py @@ -30,6 +30,11 @@ "retrieveMaxChars": 6000, "retrievePromptPreamble": "Relevant memories from Memind. Use only when directly helpful:", "retrieveContextTurns": 0, + "autoToolContext": True, + "toolContextMaxChars": 3500, + "toolContextEntryMaxChars": 520, + "toolContextMaxItems": 6, + "toolContextMinExactItems": 2, "sessionContextRecentSessions": 3, "sessionContextMaxItems": 6, "sessionContextMaxChars": 6000, @@ -53,6 +58,11 @@ "MEMIND_RETRIEVE_MAX_ENTRIES": ("retrieveMaxEntries", "int"), "MEMIND_RETRIEVE_MAX_CHARS": ("retrieveMaxChars", "int"), "MEMIND_RETRIEVE_CONTEXT_TURNS": ("retrieveContextTurns", "int_allow_zero"), + "MEMIND_AUTO_TOOL_CONTEXT": ("autoToolContext", "bool"), + "MEMIND_TOOL_CONTEXT_MAX_CHARS": ("toolContextMaxChars", "int"), + "MEMIND_TOOL_CONTEXT_ENTRY_MAX_CHARS": ("toolContextEntryMaxChars", "int"), + "MEMIND_TOOL_CONTEXT_MAX_ITEMS": ("toolContextMaxItems", "int"), + "MEMIND_TOOL_CONTEXT_MIN_EXACT_ITEMS": ("toolContextMinExactItems", "int_allow_zero"), "MEMIND_SESSION_CONTEXT_RECENT_SESSIONS": ("sessionContextRecentSessions", "int"), "MEMIND_SESSION_CONTEXT_MAX_ITEMS": ("sessionContextMaxItems", "int"), "MEMIND_SESSION_CONTEXT_MAX_CHARS": ("sessionContextMaxChars", "int"), diff --git a/memind-integrations/claude-code/settings.json b/memind-integrations/claude-code/settings.json index 1a477e24..79eac389 100644 --- a/memind-integrations/claude-code/settings.json +++ b/memind-integrations/claude-code/settings.json @@ -12,6 +12,11 @@ "retrieveMaxChars": 6000, "retrievePromptPreamble": "Relevant memories from Memind. Use only when directly helpful:", "retrieveContextTurns": 0, + "autoToolContext": true, + "toolContextMaxChars": 3500, + "toolContextEntryMaxChars": 520, + "toolContextMaxItems": 6, + "toolContextMinExactItems": 2, "sessionContextRecentSessions": 3, "sessionContextMaxItems": 6, "sessionContextMaxChars": 6000, diff --git a/memind-integrations/claude-code/tests/test_config.py b/memind-integrations/claude-code/tests/test_config.py index bf044489..dfa0125c 100644 --- a/memind-integrations/claude-code/tests/test_config.py +++ b/memind-integrations/claude-code/tests/test_config.py @@ -21,6 +21,8 @@ from scripts.lib.config import DEFAULT_SETTINGS, load_config, parse_bool, parse_int, parse_list +ROOT = Path(__file__).resolve().parents[1] + class ConfigTest(unittest.TestCase): def test_parse_bool(self): @@ -48,6 +50,11 @@ def test_defaults_match_spec(self): self.assertEqual(DEFAULT_SETTINGS["sessionContextRecentSessions"], 3) self.assertEqual(DEFAULT_SETTINGS["sessionContextMaxItems"], 6) self.assertEqual(DEFAULT_SETTINGS["sessionContextMaxChars"], 6000) + self.assertTrue(DEFAULT_SETTINGS["autoToolContext"]) + self.assertEqual(DEFAULT_SETTINGS["toolContextMaxChars"], 3500) + self.assertEqual(DEFAULT_SETTINGS["toolContextEntryMaxChars"], 520) + self.assertEqual(DEFAULT_SETTINGS["toolContextMaxItems"], 6) + self.assertEqual(DEFAULT_SETTINGS["toolContextMinExactItems"], 2) self.assertTrue(DEFAULT_SETTINGS["autoIngestAgentTimeline"]) self.assertNotIn("agentIdMode", DEFAULT_SETTINGS) self.assertNotIn("autoIngest", DEFAULT_SETTINGS) @@ -86,6 +93,26 @@ def test_environment_overrides(self): self.assertNotIn("ingestionRoles", config) self.assertEqual(config["stateMaxAgeDays"], 30) + def test_tool_context_env_overrides(self): + config = load_config( + plugin_root=ROOT, + user_config_path=Path("/no/such/file"), + env={ + "CLAUDE_PLUGIN_ROOT": str(ROOT), + "MEMIND_AUTO_TOOL_CONTEXT": "false", + "MEMIND_TOOL_CONTEXT_MAX_CHARS": "2500", + "MEMIND_TOOL_CONTEXT_ENTRY_MAX_CHARS": "400", + "MEMIND_TOOL_CONTEXT_MAX_ITEMS": "4", + "MEMIND_TOOL_CONTEXT_MIN_EXACT_ITEMS": "1", + }, + ) + + self.assertFalse(config["autoToolContext"]) + self.assertEqual(config["toolContextMaxChars"], 2500) + self.assertEqual(config["toolContextEntryMaxChars"], 400) + self.assertEqual(config["toolContextMaxItems"], 4) + self.assertEqual(config["toolContextMinExactItems"], 1) + if __name__ == "__main__": unittest.main() diff --git a/memind-integrations/claude-code/tests/test_manifest.py b/memind-integrations/claude-code/tests/test_manifest.py index 38ed3ea2..299574c2 100644 --- a/memind-integrations/claude-code/tests/test_manifest.py +++ b/memind-integrations/claude-code/tests/test_manifest.py @@ -44,8 +44,10 @@ def test_hooks_json_shape(self): self.assertNotIn("matcher", event_hooks[0]) self.assertIn("hooks", event_hooks[0]) stop_command = hooks["Stop"][0]["hooks"][0] + pre_tool_hook = hooks["PreToolUse"][0]["hooks"][0] self.assertTrue(stop_command["async"]) - self.assertTrue(hooks["PreToolUse"][0]["hooks"][0]["async"]) + self.assertNotIn("async", pre_tool_hook) + self.assertLessEqual(pre_tool_hook["timeout"], 5) self.assertTrue(hooks["PostToolUse"][0]["hooks"][0]["async"]) self.assertTrue(hooks["Notification"][0]["hooks"][0]["async"]) self.assertTrue(hooks["SubagentStop"][0]["hooks"][0]["async"]) diff --git a/memind-integrations/codex/scripts/lib/config.py b/memind-integrations/codex/scripts/lib/config.py index eb61347d..0004370a 100644 --- a/memind-integrations/codex/scripts/lib/config.py +++ b/memind-integrations/codex/scripts/lib/config.py @@ -30,6 +30,11 @@ "retrieveMaxChars": 6000, "retrievePromptPreamble": "Relevant memories from Memind. Use only when directly helpful:", "retrieveContextTurns": 0, + "autoToolContext": True, + "toolContextMaxChars": 3500, + "toolContextEntryMaxChars": 520, + "toolContextMaxItems": 6, + "toolContextMinExactItems": 2, "sessionContextRecentSessions": 3, "sessionContextMaxItems": 6, "sessionContextMaxChars": 6000, @@ -53,6 +58,11 @@ "MEMIND_RETRIEVE_MAX_ENTRIES": ("retrieveMaxEntries", "int"), "MEMIND_RETRIEVE_MAX_CHARS": ("retrieveMaxChars", "int"), "MEMIND_RETRIEVE_CONTEXT_TURNS": ("retrieveContextTurns", "int_allow_zero"), + "MEMIND_AUTO_TOOL_CONTEXT": ("autoToolContext", "bool"), + "MEMIND_TOOL_CONTEXT_MAX_CHARS": ("toolContextMaxChars", "int"), + "MEMIND_TOOL_CONTEXT_ENTRY_MAX_CHARS": ("toolContextEntryMaxChars", "int"), + "MEMIND_TOOL_CONTEXT_MAX_ITEMS": ("toolContextMaxItems", "int"), + "MEMIND_TOOL_CONTEXT_MIN_EXACT_ITEMS": ("toolContextMinExactItems", "int_allow_zero"), "MEMIND_SESSION_CONTEXT_RECENT_SESSIONS": ("sessionContextRecentSessions", "int"), "MEMIND_SESSION_CONTEXT_MAX_ITEMS": ("sessionContextMaxItems", "int"), "MEMIND_SESSION_CONTEXT_MAX_CHARS": ("sessionContextMaxChars", "int"), diff --git a/memind-integrations/codex/settings.json b/memind-integrations/codex/settings.json index 68c36ce7..0e38501f 100644 --- a/memind-integrations/codex/settings.json +++ b/memind-integrations/codex/settings.json @@ -12,6 +12,11 @@ "retrieveMaxChars": 6000, "retrievePromptPreamble": "Relevant memories from Memind. Use only when directly helpful:", "retrieveContextTurns": 0, + "autoToolContext": true, + "toolContextMaxChars": 3500, + "toolContextEntryMaxChars": 520, + "toolContextMaxItems": 6, + "toolContextMinExactItems": 2, "sessionContextRecentSessions": 3, "sessionContextMaxItems": 6, "sessionContextMaxChars": 6000, diff --git a/memind-integrations/codex/tests/test_config.py b/memind-integrations/codex/tests/test_config.py index 1ee75813..c405a183 100644 --- a/memind-integrations/codex/tests/test_config.py +++ b/memind-integrations/codex/tests/test_config.py @@ -17,7 +17,7 @@ import unittest from pathlib import Path -from scripts.lib.config import load_config, parse_bool, parse_list +from scripts.lib.config import DEFAULT_SETTINGS, load_config, parse_bool, parse_list class ConfigTest(unittest.TestCase): @@ -30,6 +30,11 @@ def test_defaults_are_codex_specific(self): self.assertEqual(config["sessionContextRecentSessions"], 3) self.assertEqual(config["sessionContextMaxItems"], 6) self.assertEqual(config["sessionContextMaxChars"], 6000) + self.assertTrue(DEFAULT_SETTINGS["autoToolContext"]) + self.assertEqual(DEFAULT_SETTINGS["toolContextMaxChars"], 3500) + self.assertEqual(DEFAULT_SETTINGS["toolContextEntryMaxChars"], 520) + self.assertEqual(DEFAULT_SETTINGS["toolContextMaxItems"], 6) + self.assertEqual(DEFAULT_SETTINGS["toolContextMinExactItems"], 2) self.assertNotIn("agentIdMode", config) self.assertNotIn("commitOnStop", config) @@ -66,6 +71,26 @@ def test_parse_helpers(self): self.assertFalse(parse_bool("0")) self.assertEqual(parse_list(" user, assistant ,, "), ["user", "assistant"]) + def test_tool_context_env_overrides(self): + config = load_config( + plugin_root=Path(__file__).resolve().parents[1], + user_config_path=Path("/no/such/file"), + env={ + "CODEX_PLUGIN_ROOT": str(Path(__file__).resolve().parents[1]), + "MEMIND_AUTO_TOOL_CONTEXT": "false", + "MEMIND_TOOL_CONTEXT_MAX_CHARS": "2500", + "MEMIND_TOOL_CONTEXT_ENTRY_MAX_CHARS": "400", + "MEMIND_TOOL_CONTEXT_MAX_ITEMS": "4", + "MEMIND_TOOL_CONTEXT_MIN_EXACT_ITEMS": "1", + }, + ) + + self.assertFalse(config["autoToolContext"]) + self.assertEqual(config["toolContextMaxChars"], 2500) + self.assertEqual(config["toolContextEntryMaxChars"], 400) + self.assertEqual(config["toolContextMaxItems"], 4) + self.assertEqual(config["toolContextMinExactItems"], 1) + if __name__ == "__main__": unittest.main() diff --git a/memind-integrations/codex/tests/test_manifest.py b/memind-integrations/codex/tests/test_manifest.py index 6fab9099..2788a521 100644 --- a/memind-integrations/codex/tests/test_manifest.py +++ b/memind-integrations/codex/tests/test_manifest.py @@ -46,6 +46,7 @@ def test_hooks_json_shape(self): self.assertEqual(command_hook["type"], "command") self.assertIn("${CODEX_PLUGIN_ROOT}/scripts/", command_hook["command"]) self.assertNotIn("async", command_hook) + self.assertNotIn("async", hooks["PreToolUse"][0]["hooks"][0]) def test_default_settings_match_spec(self): settings = json.loads((ROOT / "settings.json").read_text()) From f34b19776d9f1e7bef7d09a3d263adbf8d9db268 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 14:55:21 +0800 Subject: [PATCH 40/54] feat(agent): support structured retrieve in integrations --- .../claude-code/scripts/lib/client.py | 38 +++++++++++- .../claude-code/tests/test_client.py | 59 ++++++++++++++++++- .../codex/scripts/lib/client.py | 38 +++++++++++- .../codex/tests/test_client.py | 59 ++++++++++++++++++- 4 files changed, 188 insertions(+), 6 deletions(-) diff --git a/memind-integrations/claude-code/scripts/lib/client.py b/memind-integrations/claude-code/scripts/lib/client.py index f0d655db..0f04473b 100644 --- a/memind-integrations/claude-code/scripts/lib/client.py +++ b/memind-integrations/claude-code/scripts/lib/client.py @@ -60,8 +60,37 @@ async def commit(self, user_id, agent_id, source_client=None): source_client=source_client, ) - def retrieve(self, user_id, agent_id, query, strategy="SIMPLE", trace=False): - from memind import MemindClient as OfficialMemindClient + def retrieve( + self, + user_id, + agent_id, + query, + strategy="SIMPLE", + trace=False, + scope=None, + categories=None, + time_range=None, + metadata_filter=None, + include=None, + ): + from memind import ( + MemindClient as OfficialMemindClient, + MetadataFilter, + RetrieveIncludeOptions, + TimeRange, + ) + + metadata_filter_obj = ( + MetadataFilter(**metadata_filter) + if isinstance(metadata_filter, dict) + else metadata_filter + ) + include_obj = ( + RetrieveIncludeOptions(**include) + if isinstance(include, dict) + else include + ) + time_range_obj = TimeRange(**time_range) if isinstance(time_range, dict) else time_range with OfficialMemindClient( base_url=self.base_url, @@ -75,6 +104,11 @@ def retrieve(self, user_id, agent_id, query, strategy="SIMPLE", trace=False): query=query, strategy=strategy, trace=trace, + scope=scope, + categories=categories, + time_range=time_range_obj, + metadata_filter=metadata_filter_obj, + include=include_obj, ) def query_items( diff --git a/memind-integrations/claude-code/tests/test_client.py b/memind-integrations/claude-code/tests/test_client.py index 50777a47..1400826b 100644 --- a/memind-integrations/claude-code/tests/test_client.py +++ b/memind-integrations/claude-code/tests/test_client.py @@ -117,8 +117,34 @@ def __init__(self, **kwargs): class _MetadataFilter: - def __init__(self, all=None, **kwargs): + def __init__(self, all=None, any=None, not_=None, **kwargs): + excluded = kwargs.get("not", not_) self.all = [_MetadataCondition(**item) for item in (all or [])] + self.any = [_MetadataCondition(**item) for item in (any or [])] + self.not_ = [_MetadataCondition(**item) for item in (excluded or [])] + + +class _RetrieveIncludeOptions: + def __init__( + self, + raw_data_metadata=None, + rawDataMetadata=None, + raw_data_segment=None, + rawDataSegment=None, + ): + self.raw_data_metadata = ( + raw_data_metadata if raw_data_metadata is not None else rawDataMetadata + ) + self.raw_data_segment = ( + raw_data_segment if raw_data_segment is not None else rawDataSegment + ) + + +class _TimeRange: + def __init__(self, field=None, from_=None, to=None, **kwargs): + self.field = field + self.from_ = kwargs.get("from", from_) + self.to = to class _RawDataQueryIncludeOptions: @@ -145,6 +171,9 @@ def _fake_memind_module(): module.AsyncMemindClient = _FakeAsyncMemindClient module.MemindClient = _FakeSyncMemindClient module.Strategy = _Strategy + module.MetadataFilter = _MetadataFilter + module.RetrieveIncludeOptions = _RetrieveIncludeOptions + module.TimeRange = _TimeRange module.QueryMemoryItemsRequest = _QueryMemoryItemsRequest module.QueryMemoryRawDataRequest = _QueryMemoryRawDataRequest module.RawDataQueryIncludeOptions = _RawDataQueryIncludeOptions @@ -205,6 +234,34 @@ def test_retrieve_uses_official_sync_client_and_returns_model(self): self.assertEqual(retrieve_call["strategy"], "SIMPLE") self.assertFalse(retrieve_call["trace"]) + def test_retrieve_passes_structured_filters(self): + with mock.patch.dict(sys.modules, {"memind": _fake_memind_module()}): + MemindClient = _load_client_class() + client = MemindClient("http://memind", "token", timeout=1, max_retries=0) + result = client.retrieve( + "u", + "a", + "payment context", + "SIMPLE", + False, + scope="AGENT", + categories=["resolution", "tool"], + metadata_filter={ + "all": [{"path": "projectSlug", "op": "eq", "value": "payment"}], + "any": [{"path": "files", "op": "contains", "value": "src/payment/calc.ts"}], + }, + include={"rawDataMetadata": True}, + ) + + self.assertIsNotNone(result) + instance = _FakeSyncMemindClient.instances[0] + retrieve_call = instance.memory.calls[0][1] + self.assertEqual(retrieve_call["scope"], "AGENT") + self.assertEqual(retrieve_call["categories"], ["resolution", "tool"]) + self.assertEqual(retrieve_call["metadata_filter"].all[0].path, "projectSlug") + self.assertEqual(retrieve_call["metadata_filter"].any[0].path, "files") + self.assertTrue(retrieve_call["include"].raw_data_metadata) + def test_query_wrappers_use_official_sync_query_models(self): with mock.patch.dict(sys.modules, {"memind": _fake_memind_module()}): MemindClient = _load_client_class() diff --git a/memind-integrations/codex/scripts/lib/client.py b/memind-integrations/codex/scripts/lib/client.py index ac559888..5a2127c7 100644 --- a/memind-integrations/codex/scripts/lib/client.py +++ b/memind-integrations/codex/scripts/lib/client.py @@ -59,8 +59,37 @@ async def commit(self, user_id, agent_id, source_client=None): source_client=source_client, ) - def retrieve(self, user_id, agent_id, query, strategy="SIMPLE", trace=False): - from memind import MemindClient as OfficialMemindClient + def retrieve( + self, + user_id, + agent_id, + query, + strategy="SIMPLE", + trace=False, + scope=None, + categories=None, + time_range=None, + metadata_filter=None, + include=None, + ): + from memind import ( + MemindClient as OfficialMemindClient, + MetadataFilter, + RetrieveIncludeOptions, + TimeRange, + ) + + metadata_filter_obj = ( + MetadataFilter(**metadata_filter) + if isinstance(metadata_filter, dict) + else metadata_filter + ) + include_obj = ( + RetrieveIncludeOptions(**include) + if isinstance(include, dict) + else include + ) + time_range_obj = TimeRange(**time_range) if isinstance(time_range, dict) else time_range with OfficialMemindClient( base_url=self.base_url, @@ -74,6 +103,11 @@ def retrieve(self, user_id, agent_id, query, strategy="SIMPLE", trace=False): query=query, strategy=strategy, trace=trace, + scope=scope, + categories=categories, + time_range=time_range_obj, + metadata_filter=metadata_filter_obj, + include=include_obj, ) def query_items( diff --git a/memind-integrations/codex/tests/test_client.py b/memind-integrations/codex/tests/test_client.py index 6af43da9..eea76a91 100644 --- a/memind-integrations/codex/tests/test_client.py +++ b/memind-integrations/codex/tests/test_client.py @@ -117,8 +117,34 @@ def __init__(self, **kwargs): class _MetadataFilter: - def __init__(self, all=None, **kwargs): + def __init__(self, all=None, any=None, not_=None, **kwargs): + excluded = kwargs.get("not", not_) self.all = [_MetadataCondition(**item) for item in (all or [])] + self.any = [_MetadataCondition(**item) for item in (any or [])] + self.not_ = [_MetadataCondition(**item) for item in (excluded or [])] + + +class _RetrieveIncludeOptions: + def __init__( + self, + raw_data_metadata=None, + rawDataMetadata=None, + raw_data_segment=None, + rawDataSegment=None, + ): + self.raw_data_metadata = ( + raw_data_metadata if raw_data_metadata is not None else rawDataMetadata + ) + self.raw_data_segment = ( + raw_data_segment if raw_data_segment is not None else rawDataSegment + ) + + +class _TimeRange: + def __init__(self, field=None, from_=None, to=None, **kwargs): + self.field = field + self.from_ = kwargs.get("from", from_) + self.to = to class _RawDataQueryIncludeOptions: @@ -145,6 +171,9 @@ def _fake_memind_module(): module.AsyncMemindClient = _FakeAsyncMemindClient module.MemindClient = _FakeSyncMemindClient module.Strategy = _Strategy + module.MetadataFilter = _MetadataFilter + module.RetrieveIncludeOptions = _RetrieveIncludeOptions + module.TimeRange = _TimeRange module.QueryMemoryItemsRequest = _QueryMemoryItemsRequest module.QueryMemoryRawDataRequest = _QueryMemoryRawDataRequest module.RawDataQueryIncludeOptions = _RawDataQueryIncludeOptions @@ -197,6 +226,34 @@ def test_retrieve_uses_official_sync_client(self): self.assertEqual(result.model_dump(by_alias=True)["items"][0]["text"], "remember espresso") self.assertTrue(_FakeSyncMemindClient.instances[0].closed) + def test_retrieve_passes_structured_filters(self): + with mock.patch.dict(sys.modules, {"memind": _fake_memind_module()}): + MemindClient = _load_client_class() + client = MemindClient("http://memind", "token", timeout=1, max_retries=0) + result = client.retrieve( + "u", + "a", + "payment context", + "SIMPLE", + False, + scope="AGENT", + categories=["resolution", "tool"], + metadata_filter={ + "all": [{"path": "projectSlug", "op": "eq", "value": "payment"}], + "any": [{"path": "files", "op": "contains", "value": "src/payment/calc.ts"}], + }, + include={"rawDataMetadata": True}, + ) + + self.assertIsNotNone(result) + instance = _FakeSyncMemindClient.instances[0] + retrieve_call = instance.memory.calls[0][1] + self.assertEqual(retrieve_call["scope"], "AGENT") + self.assertEqual(retrieve_call["categories"], ["resolution", "tool"]) + self.assertEqual(retrieve_call["metadata_filter"].all[0].path, "projectSlug") + self.assertEqual(retrieve_call["metadata_filter"].any[0].path, "files") + self.assertTrue(retrieve_call["include"].raw_data_metadata) + def test_query_wrappers_use_official_sync_query_models(self): with mock.patch.dict(sys.modules, {"memind": _fake_memind_module()}): MemindClient = _load_client_class() From 361f338f30544ece3812e6fa44265ab3425f6f6d Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 14:57:59 +0800 Subject: [PATCH 41/54] feat(agent): extract pre-tool context targets --- .../claude-code/scripts/lib/tool_context.py | 74 +++++++++++ .../claude-code/tests/test_tool_context.py | 124 ++++++++++++++++++ .../codex/scripts/lib/tool_context.py | 74 +++++++++++ .../codex/tests/test_tool_context.py | 124 ++++++++++++++++++ 4 files changed, 396 insertions(+) create mode 100644 memind-integrations/claude-code/scripts/lib/tool_context.py create mode 100644 memind-integrations/claude-code/tests/test_tool_context.py create mode 100644 memind-integrations/codex/scripts/lib/tool_context.py create mode 100644 memind-integrations/codex/tests/test_tool_context.py diff --git a/memind-integrations/claude-code/scripts/lib/tool_context.py b/memind-integrations/claude-code/scripts/lib/tool_context.py new file mode 100644 index 00000000..01a0b569 --- /dev/null +++ b/memind-integrations/claude-code/scripts/lib/tool_context.py @@ -0,0 +1,74 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +HIGH_VALUE_KINDS = {"file_edit", "command", "test_result"} + + +def extract_tool_context_target(event, hook_input, project_slug): + metadata = event.get("metadata") or {} + target = { + "toolName": event.get("toolName"), + "kind": event.get("kind"), + "path": event.get("path"), + "command": event.get("command"), + "operation": event.get("operation"), + "validationType": metadata.get("validationType"), + "projectSlug": project_slug, + "cwd": hook_input.get("cwd"), + "turnId": metadata.get("turnId"), + "turnSeq": metadata.get("turnSeq"), + } + return {key: value for key, value in target.items() if value not in (None, "", [])} + + +def should_query_tool_context(target, config): + if not config.get("autoToolContext", True) or not config.get("autoRetrieve", True): + return False + if not target or target.get("kind") not in HIGH_VALUE_KINDS: + return False + return bool(target.get("path") or target.get("command")) + + +def build_metadata_filter(target, include_project=True): + all_conditions = [] + any_conditions = [] + if include_project and target.get("projectSlug"): + all_conditions.append( + {"path": "projectSlug", "op": "eq", "value": target["projectSlug"]} + ) + if target.get("path"): + any_conditions.append({"path": "files", "op": "contains", "value": target["path"]}) + if target.get("command"): + any_conditions.append( + {"path": "commands", "op": "contains", "value": target["command"]} + ) + if target.get("toolName"): + any_conditions.append( + {"path": "toolNames", "op": "contains", "value": target["toolName"]} + ) + return { + "all": all_conditions, + "any": any_conditions, + "not": [], + } + + +def current_turn_prompt(events, turn_id): + if not turn_id: + return "" + for event in reversed(events or []): + metadata = event.get("metadata") or {} + if event.get("kind") == "user_prompt" and metadata.get("turnId") == turn_id: + return event.get("text") or "" + return "" diff --git a/memind-integrations/claude-code/tests/test_tool_context.py b/memind-integrations/claude-code/tests/test_tool_context.py new file mode 100644 index 00000000..e87e057e --- /dev/null +++ b/memind-integrations/claude-code/tests/test_tool_context.py @@ -0,0 +1,124 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +from scripts.lib.tool_context import ( + build_metadata_filter, + current_turn_prompt, + extract_tool_context_target, + should_query_tool_context, +) + + +class ToolContextTest(unittest.TestCase): + def test_extracts_file_edit_target(self): + target = extract_tool_context_target( + { + "kind": "file_edit", + "toolName": "Edit", + "path": "src/payment/calc.ts", + "operation": "edit", + "metadata": {"turnId": "s-turn-1"}, + }, + {"cwd": "/repo/payment"}, + "payment-service-abc", + ) + + self.assertEqual(target["toolName"], "Edit") + self.assertEqual(target["kind"], "file_edit") + self.assertEqual(target["path"], "src/payment/calc.ts") + self.assertEqual(target["projectSlug"], "payment-service-abc") + + def test_extracts_command_target(self): + target = extract_tool_context_target( + { + "kind": "test_result", + "toolName": "Bash", + "command": "npm test payment", + "operation": "run", + "metadata": {"validationType": "test"}, + }, + {"cwd": "/repo/payment"}, + "payment-service-abc", + ) + + self.assertEqual(target["command"], "npm test payment") + self.assertEqual(target["validationType"], "test") + + def test_skips_low_value_tools(self): + target = extract_tool_context_target( + {"kind": "file_read", "toolName": "Read", "path": "README.md"}, + {"cwd": "/repo/payment"}, + "payment-service-abc", + ) + + self.assertFalse(should_query_tool_context(target, {"autoToolContext": True})) + + def test_skips_when_disabled_or_no_target(self): + self.assertFalse(should_query_tool_context({}, {"autoToolContext": True})) + self.assertFalse( + should_query_tool_context( + {"kind": "file_edit", "path": "src/a.ts"}, {"autoToolContext": False} + ) + ) + + def test_builds_top_level_metadata_filter(self): + metadata_filter = build_metadata_filter( + { + "projectSlug": "payment-service-abc", + "path": "src/payment/calc.ts", + "command": "npm test payment", + "toolName": "Bash", + }, + include_project=True, + ) + + self.assertEqual( + metadata_filter["all"], + [{"path": "projectSlug", "op": "eq", "value": "payment-service-abc"}], + ) + self.assertIn( + {"path": "files", "op": "contains", "value": "src/payment/calc.ts"}, + metadata_filter["any"], + ) + self.assertIn( + {"path": "commands", "op": "contains", "value": "npm test payment"}, + metadata_filter["any"], + ) + self.assertIn( + {"path": "toolNames", "op": "contains", "value": "Bash"}, + metadata_filter["any"], + ) + + def test_current_turn_prompt_uses_matching_turn_id(self): + prompt = current_turn_prompt( + [ + {"kind": "user_prompt", "text": "older", "metadata": {"turnId": "t0"}}, + {"kind": "user_prompt", "text": "Fix payment tests", "metadata": {"turnId": "t1"}}, + {"kind": "file_edit", "metadata": {"turnId": "t1"}}, + ], + "t1", + ) + + self.assertEqual(prompt, "Fix payment tests") + + +if __name__ == "__main__": + unittest.main() diff --git a/memind-integrations/codex/scripts/lib/tool_context.py b/memind-integrations/codex/scripts/lib/tool_context.py new file mode 100644 index 00000000..01a0b569 --- /dev/null +++ b/memind-integrations/codex/scripts/lib/tool_context.py @@ -0,0 +1,74 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +HIGH_VALUE_KINDS = {"file_edit", "command", "test_result"} + + +def extract_tool_context_target(event, hook_input, project_slug): + metadata = event.get("metadata") or {} + target = { + "toolName": event.get("toolName"), + "kind": event.get("kind"), + "path": event.get("path"), + "command": event.get("command"), + "operation": event.get("operation"), + "validationType": metadata.get("validationType"), + "projectSlug": project_slug, + "cwd": hook_input.get("cwd"), + "turnId": metadata.get("turnId"), + "turnSeq": metadata.get("turnSeq"), + } + return {key: value for key, value in target.items() if value not in (None, "", [])} + + +def should_query_tool_context(target, config): + if not config.get("autoToolContext", True) or not config.get("autoRetrieve", True): + return False + if not target or target.get("kind") not in HIGH_VALUE_KINDS: + return False + return bool(target.get("path") or target.get("command")) + + +def build_metadata_filter(target, include_project=True): + all_conditions = [] + any_conditions = [] + if include_project and target.get("projectSlug"): + all_conditions.append( + {"path": "projectSlug", "op": "eq", "value": target["projectSlug"]} + ) + if target.get("path"): + any_conditions.append({"path": "files", "op": "contains", "value": target["path"]}) + if target.get("command"): + any_conditions.append( + {"path": "commands", "op": "contains", "value": target["command"]} + ) + if target.get("toolName"): + any_conditions.append( + {"path": "toolNames", "op": "contains", "value": target["toolName"]} + ) + return { + "all": all_conditions, + "any": any_conditions, + "not": [], + } + + +def current_turn_prompt(events, turn_id): + if not turn_id: + return "" + for event in reversed(events or []): + metadata = event.get("metadata") or {} + if event.get("kind") == "user_prompt" and metadata.get("turnId") == turn_id: + return event.get("text") or "" + return "" diff --git a/memind-integrations/codex/tests/test_tool_context.py b/memind-integrations/codex/tests/test_tool_context.py new file mode 100644 index 00000000..e87e057e --- /dev/null +++ b/memind-integrations/codex/tests/test_tool_context.py @@ -0,0 +1,124 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +from scripts.lib.tool_context import ( + build_metadata_filter, + current_turn_prompt, + extract_tool_context_target, + should_query_tool_context, +) + + +class ToolContextTest(unittest.TestCase): + def test_extracts_file_edit_target(self): + target = extract_tool_context_target( + { + "kind": "file_edit", + "toolName": "Edit", + "path": "src/payment/calc.ts", + "operation": "edit", + "metadata": {"turnId": "s-turn-1"}, + }, + {"cwd": "/repo/payment"}, + "payment-service-abc", + ) + + self.assertEqual(target["toolName"], "Edit") + self.assertEqual(target["kind"], "file_edit") + self.assertEqual(target["path"], "src/payment/calc.ts") + self.assertEqual(target["projectSlug"], "payment-service-abc") + + def test_extracts_command_target(self): + target = extract_tool_context_target( + { + "kind": "test_result", + "toolName": "Bash", + "command": "npm test payment", + "operation": "run", + "metadata": {"validationType": "test"}, + }, + {"cwd": "/repo/payment"}, + "payment-service-abc", + ) + + self.assertEqual(target["command"], "npm test payment") + self.assertEqual(target["validationType"], "test") + + def test_skips_low_value_tools(self): + target = extract_tool_context_target( + {"kind": "file_read", "toolName": "Read", "path": "README.md"}, + {"cwd": "/repo/payment"}, + "payment-service-abc", + ) + + self.assertFalse(should_query_tool_context(target, {"autoToolContext": True})) + + def test_skips_when_disabled_or_no_target(self): + self.assertFalse(should_query_tool_context({}, {"autoToolContext": True})) + self.assertFalse( + should_query_tool_context( + {"kind": "file_edit", "path": "src/a.ts"}, {"autoToolContext": False} + ) + ) + + def test_builds_top_level_metadata_filter(self): + metadata_filter = build_metadata_filter( + { + "projectSlug": "payment-service-abc", + "path": "src/payment/calc.ts", + "command": "npm test payment", + "toolName": "Bash", + }, + include_project=True, + ) + + self.assertEqual( + metadata_filter["all"], + [{"path": "projectSlug", "op": "eq", "value": "payment-service-abc"}], + ) + self.assertIn( + {"path": "files", "op": "contains", "value": "src/payment/calc.ts"}, + metadata_filter["any"], + ) + self.assertIn( + {"path": "commands", "op": "contains", "value": "npm test payment"}, + metadata_filter["any"], + ) + self.assertIn( + {"path": "toolNames", "op": "contains", "value": "Bash"}, + metadata_filter["any"], + ) + + def test_current_turn_prompt_uses_matching_turn_id(self): + prompt = current_turn_prompt( + [ + {"kind": "user_prompt", "text": "older", "metadata": {"turnId": "t0"}}, + {"kind": "user_prompt", "text": "Fix payment tests", "metadata": {"turnId": "t1"}}, + {"kind": "file_edit", "metadata": {"turnId": "t1"}}, + ], + "t1", + ) + + self.assertEqual(prompt, "Fix payment tests") + + +if __name__ == "__main__": + unittest.main() From 0d90c30bed6a9c0ffc61cee7639b0ddf7b465764 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 15:01:06 +0800 Subject: [PATCH 42/54] feat(agent): query pre-tool memory context --- .../claude-code/scripts/lib/tool_context.py | 172 ++++++++++++++++++ .../claude-code/tests/test_tool_context.py | 125 +++++++++++++ .../codex/scripts/lib/tool_context.py | 172 ++++++++++++++++++ .../codex/tests/test_tool_context.py | 125 +++++++++++++ 4 files changed, 594 insertions(+) diff --git a/memind-integrations/claude-code/scripts/lib/tool_context.py b/memind-integrations/claude-code/scripts/lib/tool_context.py index 01a0b569..799f847e 100644 --- a/memind-integrations/claude-code/scripts/lib/tool_context.py +++ b/memind-integrations/claude-code/scripts/lib/tool_context.py @@ -13,6 +13,7 @@ # HIGH_VALUE_KINDS = {"file_edit", "command", "test_result"} +TOOL_CONTEXT_CATEGORIES = ["resolution", "tool", "playbook", "directive"] def extract_tool_context_target(event, hook_input, project_slug): @@ -72,3 +73,174 @@ def current_turn_prompt(events, turn_id): if event.get("kind") == "user_prompt" and metadata.get("turnId") == turn_id: return event.get("text") or "" return "" + + +def load_tool_context(client, user_id, agent_id, target, config): + max_items = int(config.get("toolContextMaxItems", 6)) + min_exact = int(config.get("toolContextMinExactItems", 2)) + exact_items = [] + + for include_project in [True, False]: + metadata_filter = build_metadata_filter(target, include_project=include_project) + if not metadata_filter["any"]: + continue + response = client.query_items( + user_id=user_id, + agent_id=agent_id, + scope=None, + categories=TOOL_CONTEXT_CATEGORIES, + source_clients=None, + raw_data_types=["agent_timeline"], + metadata_filter=metadata_filter, + limit=max(10, max_items * 3), + ) + exact_items.extend(_normalize_items(getattr(response, "items", []) or [])) + if len(exact_items) >= max_items: + break + + raw_data = [] + metadata_filter = build_metadata_filter(target, include_project=bool(target.get("projectSlug"))) + if metadata_filter["any"]: + response = client.query_raw_data( + user_id=user_id, + agent_id=agent_id, + types=["agent_timeline"], + source_clients=None, + metadata_filter=metadata_filter, + include={"metadata": True, "segment": False}, + limit=max(6, max_items), + ) + raw_data = _normalize_raw_data(getattr(response, "raw_data", []) or []) + + fallback_items = [] + if len(exact_items) < min_exact: + retrieve_response = client.retrieve( + user_id, + agent_id, + _semantic_query(target), + config.get("retrieveStrategy", "SIMPLE"), + False, + scope=None, + categories=TOOL_CONTEXT_CATEGORIES, + metadata_filter=( + {"all": [{"path": "projectSlug", "op": "eq", "value": target["projectSlug"]}]} + if target.get("projectSlug") + else None + ), + include={"rawDataMetadata": True}, + ) + fallback_items = _normalize_items(getattr(retrieve_response, "items", []) or []) + + items = _dedupe_by_id(exact_items + fallback_items) + return { + "target": dict(target), + "items": rank_items(items, target)[:max_items], + "rawData": rank_raw_data(raw_data, target)[:max_items], + } + + +def _semantic_query(target): + parts = [] + if target.get("prompt"): + parts.append("task: " + target["prompt"]) + if target.get("path"): + parts.append("file: " + target["path"]) + if target.get("command"): + parts.append("command: " + target["command"]) + if target.get("toolName"): + parts.append("tool: " + target["toolName"]) + return "\n".join(parts) or "coding agent tool context" + + +def _normalize_items(items): + result = [] + for item in items: + result.append( + { + "id": _field(item, "id"), + "text": _field(item, "text"), + "category": str(_field(item, "category") or "memory").lower(), + "createdAt": _field(item, "createdAt") or _field(item, "created_at"), + "metadata": _field(item, "metadata") or {}, + } + ) + return [item for item in result if item.get("text")] + + +def _normalize_raw_data(raw_data): + result = [] + for raw in raw_data: + result.append( + { + "id": _field(raw, "rawDataId") or _field(raw, "raw_data_id") or _field(raw, "id"), + "caption": _field(raw, "caption"), + "type": _field(raw, "type"), + "createdAt": _field(raw, "createdAt") or _field(raw, "created_at"), + "metadata": _field(raw, "metadata") or {}, + } + ) + return [raw for raw in result if raw.get("caption") or raw.get("metadata")] + + +def rank_items(items, target): + return sorted( + items, + key=lambda item: ( + _match_score(item.get("metadata") or {}, target), + _category_score(item.get("category")), + item.get("createdAt") or "", + item.get("id") or "", + ), + reverse=True, + ) + + +def rank_raw_data(raw_data, target): + return sorted( + raw_data, + key=lambda raw: ( + _match_score(raw.get("metadata") or {}, target), + raw.get("createdAt") or "", + raw.get("id") or "", + ), + reverse=True, + ) + + +def _match_score(metadata, target): + score = 0 + if target.get("projectSlug") and metadata.get("projectSlug") == target["projectSlug"]: + score += 3 + if target.get("path") and target["path"] in metadata.get("files", []): + score += 10 + if target.get("command") and target["command"] in metadata.get("commands", []): + score += 8 + if target.get("toolName") and target["toolName"] in metadata.get("toolNames", []): + score += 3 + stats = metadata.get("toolStats") or {} + if target.get("toolName") in stats: + tool_stats = stats[target["toolName"]] + score += int(tool_stats.get("successCount") or 0) + return score + + +def _category_score(category): + return {"resolution": 5, "tool": 4, "playbook": 3, "directive": 2}.get(category or "", 1) + + +def _dedupe_by_id(items): + result = [] + seen = set() + for item in items: + key = item.get("id") or item.get("text") + if key in seen: + continue + seen.add(key) + result.append(item) + return result + + +def _field(value, name): + if isinstance(value, dict): + return value.get(name) + return getattr(value, name, None) diff --git a/memind-integrations/claude-code/tests/test_tool_context.py b/memind-integrations/claude-code/tests/test_tool_context.py index e87e057e..8bfad43c 100644 --- a/memind-integrations/claude-code/tests/test_tool_context.py +++ b/memind-integrations/claude-code/tests/test_tool_context.py @@ -15,6 +15,7 @@ import sys import unittest from pathlib import Path +from types import SimpleNamespace ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) @@ -27,6 +28,56 @@ ) +class FakeClient: + def __init__(self): + self.item_queries = [] + self.raw_queries = [] + self.retrieve_queries = [] + + def query_items(self, **kwargs): + self.item_queries.append(kwargs) + if kwargs.get("metadata_filter", {}).get("all"): + return SimpleNamespace( + items=[ + SimpleNamespace( + id="res-1", + text="rounding mismatch was resolved in src/payment/calc.ts and validated with npm test payment.", + category="resolution", + created_at="2026-05-27T10:00:00Z", + metadata={ + "projectSlug": "payment-service-abc", + "files": ["src/payment/calc.ts"], + "commands": ["npm test payment"], + }, + ) + ] + ) + return SimpleNamespace(items=[]) + + def query_raw_data(self, **kwargs): + self.raw_queries.append(kwargs) + return SimpleNamespace( + raw_data=[ + SimpleNamespace( + id="rd-1", + caption="Edited src/payment/calc.ts and validated npm test payment.", + type="agent_timeline", + created_at="2026-05-27T10:05:00Z", + metadata={ + "projectSlug": "payment-service-abc", + "files": ["src/payment/calc.ts"], + "commands": ["npm test payment"], + "toolStats": {"Bash": {"successCount": 1, "failCount": 1}}, + }, + ) + ] + ) + + def retrieve(self, *args, **kwargs): + self.retrieve_queries.append(kwargs) + return SimpleNamespace(items=[], insights=[], raw_data=[]) + + class ToolContextTest(unittest.TestCase): def test_extracts_file_edit_target(self): target = extract_tool_context_target( @@ -119,6 +170,80 @@ def test_current_turn_prompt_uses_matching_turn_id(self): self.assertEqual(prompt, "Fix payment tests") + def test_load_tool_context_uses_exact_queries_first(self): + from scripts.lib.tool_context import load_tool_context + + client = FakeClient() + context = load_tool_context( + client, + "u", + "a", + { + "toolName": "Edit", + "kind": "file_edit", + "path": "src/payment/calc.ts", + "projectSlug": "payment-service-abc", + "prompt": "Fix payment tests", + }, + {"toolContextMaxItems": 6, "toolContextMinExactItems": 2}, + ) + + self.assertEqual(len(client.item_queries), 2) + self.assertEqual(len(client.raw_queries), 1) + self.assertEqual(client.item_queries[0]["categories"], ["resolution", "tool", "playbook", "directive"]) + self.assertEqual(client.item_queries[0]["metadata_filter"]["all"][0]["path"], "projectSlug") + self.assertEqual(client.raw_queries[0]["types"], ["agent_timeline"]) + self.assertEqual(context["target"]["path"], "src/payment/calc.ts") + self.assertEqual(context["items"][0]["category"], "resolution") + self.assertEqual(context["rawData"][0]["id"], "rd-1") + + def test_load_tool_context_uses_retrieve_fallback_when_exact_hits_are_sparse(self): + from scripts.lib.tool_context import load_tool_context + + class SparseClient(FakeClient): + def query_items(self, **kwargs): + self.item_queries.append(kwargs) + return SimpleNamespace(items=[]) + + def query_raw_data(self, **kwargs): + self.raw_queries.append(kwargs) + return SimpleNamespace(raw_data=[]) + + def retrieve(self, *args, **kwargs): + self.retrieve_queries.append(kwargs) + return SimpleNamespace( + items=[ + SimpleNamespace( + id="tool-1", + text="Use npm test payment after editing payment calculation files.", + category="tool", + created_at="2026-05-27T10:00:00Z", + metadata={"commands": ["npm test payment"]}, + ) + ], + insights=[], + raw_data=[], + ) + + client = SparseClient() + context = load_tool_context( + client, + "u", + "a", + { + "toolName": "Bash", + "kind": "test_result", + "command": "npm test payment", + "projectSlug": "payment-service-abc", + "prompt": "Fix payment tests", + }, + {"toolContextMaxItems": 6, "toolContextMinExactItems": 1}, + ) + + self.assertEqual(len(client.retrieve_queries), 1) + self.assertEqual(client.retrieve_queries[0]["categories"], ["resolution", "tool", "playbook", "directive"]) + self.assertEqual(context["items"][0]["id"], "tool-1") + if __name__ == "__main__": unittest.main() diff --git a/memind-integrations/codex/scripts/lib/tool_context.py b/memind-integrations/codex/scripts/lib/tool_context.py index 01a0b569..799f847e 100644 --- a/memind-integrations/codex/scripts/lib/tool_context.py +++ b/memind-integrations/codex/scripts/lib/tool_context.py @@ -13,6 +13,7 @@ # HIGH_VALUE_KINDS = {"file_edit", "command", "test_result"} +TOOL_CONTEXT_CATEGORIES = ["resolution", "tool", "playbook", "directive"] def extract_tool_context_target(event, hook_input, project_slug): @@ -72,3 +73,174 @@ def current_turn_prompt(events, turn_id): if event.get("kind") == "user_prompt" and metadata.get("turnId") == turn_id: return event.get("text") or "" return "" + + +def load_tool_context(client, user_id, agent_id, target, config): + max_items = int(config.get("toolContextMaxItems", 6)) + min_exact = int(config.get("toolContextMinExactItems", 2)) + exact_items = [] + + for include_project in [True, False]: + metadata_filter = build_metadata_filter(target, include_project=include_project) + if not metadata_filter["any"]: + continue + response = client.query_items( + user_id=user_id, + agent_id=agent_id, + scope=None, + categories=TOOL_CONTEXT_CATEGORIES, + source_clients=None, + raw_data_types=["agent_timeline"], + metadata_filter=metadata_filter, + limit=max(10, max_items * 3), + ) + exact_items.extend(_normalize_items(getattr(response, "items", []) or [])) + if len(exact_items) >= max_items: + break + + raw_data = [] + metadata_filter = build_metadata_filter(target, include_project=bool(target.get("projectSlug"))) + if metadata_filter["any"]: + response = client.query_raw_data( + user_id=user_id, + agent_id=agent_id, + types=["agent_timeline"], + source_clients=None, + metadata_filter=metadata_filter, + include={"metadata": True, "segment": False}, + limit=max(6, max_items), + ) + raw_data = _normalize_raw_data(getattr(response, "raw_data", []) or []) + + fallback_items = [] + if len(exact_items) < min_exact: + retrieve_response = client.retrieve( + user_id, + agent_id, + _semantic_query(target), + config.get("retrieveStrategy", "SIMPLE"), + False, + scope=None, + categories=TOOL_CONTEXT_CATEGORIES, + metadata_filter=( + {"all": [{"path": "projectSlug", "op": "eq", "value": target["projectSlug"]}]} + if target.get("projectSlug") + else None + ), + include={"rawDataMetadata": True}, + ) + fallback_items = _normalize_items(getattr(retrieve_response, "items", []) or []) + + items = _dedupe_by_id(exact_items + fallback_items) + return { + "target": dict(target), + "items": rank_items(items, target)[:max_items], + "rawData": rank_raw_data(raw_data, target)[:max_items], + } + + +def _semantic_query(target): + parts = [] + if target.get("prompt"): + parts.append("task: " + target["prompt"]) + if target.get("path"): + parts.append("file: " + target["path"]) + if target.get("command"): + parts.append("command: " + target["command"]) + if target.get("toolName"): + parts.append("tool: " + target["toolName"]) + return "\n".join(parts) or "coding agent tool context" + + +def _normalize_items(items): + result = [] + for item in items: + result.append( + { + "id": _field(item, "id"), + "text": _field(item, "text"), + "category": str(_field(item, "category") or "memory").lower(), + "createdAt": _field(item, "createdAt") or _field(item, "created_at"), + "metadata": _field(item, "metadata") or {}, + } + ) + return [item for item in result if item.get("text")] + + +def _normalize_raw_data(raw_data): + result = [] + for raw in raw_data: + result.append( + { + "id": _field(raw, "rawDataId") or _field(raw, "raw_data_id") or _field(raw, "id"), + "caption": _field(raw, "caption"), + "type": _field(raw, "type"), + "createdAt": _field(raw, "createdAt") or _field(raw, "created_at"), + "metadata": _field(raw, "metadata") or {}, + } + ) + return [raw for raw in result if raw.get("caption") or raw.get("metadata")] + + +def rank_items(items, target): + return sorted( + items, + key=lambda item: ( + _match_score(item.get("metadata") or {}, target), + _category_score(item.get("category")), + item.get("createdAt") or "", + item.get("id") or "", + ), + reverse=True, + ) + + +def rank_raw_data(raw_data, target): + return sorted( + raw_data, + key=lambda raw: ( + _match_score(raw.get("metadata") or {}, target), + raw.get("createdAt") or "", + raw.get("id") or "", + ), + reverse=True, + ) + + +def _match_score(metadata, target): + score = 0 + if target.get("projectSlug") and metadata.get("projectSlug") == target["projectSlug"]: + score += 3 + if target.get("path") and target["path"] in metadata.get("files", []): + score += 10 + if target.get("command") and target["command"] in metadata.get("commands", []): + score += 8 + if target.get("toolName") and target["toolName"] in metadata.get("toolNames", []): + score += 3 + stats = metadata.get("toolStats") or {} + if target.get("toolName") in stats: + tool_stats = stats[target["toolName"]] + score += int(tool_stats.get("successCount") or 0) + return score + + +def _category_score(category): + return {"resolution": 5, "tool": 4, "playbook": 3, "directive": 2}.get(category or "", 1) + + +def _dedupe_by_id(items): + result = [] + seen = set() + for item in items: + key = item.get("id") or item.get("text") + if key in seen: + continue + seen.add(key) + result.append(item) + return result + + +def _field(value, name): + if isinstance(value, dict): + return value.get(name) + return getattr(value, name, None) diff --git a/memind-integrations/codex/tests/test_tool_context.py b/memind-integrations/codex/tests/test_tool_context.py index e87e057e..8bfad43c 100644 --- a/memind-integrations/codex/tests/test_tool_context.py +++ b/memind-integrations/codex/tests/test_tool_context.py @@ -15,6 +15,7 @@ import sys import unittest from pathlib import Path +from types import SimpleNamespace ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) @@ -27,6 +28,56 @@ ) +class FakeClient: + def __init__(self): + self.item_queries = [] + self.raw_queries = [] + self.retrieve_queries = [] + + def query_items(self, **kwargs): + self.item_queries.append(kwargs) + if kwargs.get("metadata_filter", {}).get("all"): + return SimpleNamespace( + items=[ + SimpleNamespace( + id="res-1", + text="rounding mismatch was resolved in src/payment/calc.ts and validated with npm test payment.", + category="resolution", + created_at="2026-05-27T10:00:00Z", + metadata={ + "projectSlug": "payment-service-abc", + "files": ["src/payment/calc.ts"], + "commands": ["npm test payment"], + }, + ) + ] + ) + return SimpleNamespace(items=[]) + + def query_raw_data(self, **kwargs): + self.raw_queries.append(kwargs) + return SimpleNamespace( + raw_data=[ + SimpleNamespace( + id="rd-1", + caption="Edited src/payment/calc.ts and validated npm test payment.", + type="agent_timeline", + created_at="2026-05-27T10:05:00Z", + metadata={ + "projectSlug": "payment-service-abc", + "files": ["src/payment/calc.ts"], + "commands": ["npm test payment"], + "toolStats": {"Bash": {"successCount": 1, "failCount": 1}}, + }, + ) + ] + ) + + def retrieve(self, *args, **kwargs): + self.retrieve_queries.append(kwargs) + return SimpleNamespace(items=[], insights=[], raw_data=[]) + + class ToolContextTest(unittest.TestCase): def test_extracts_file_edit_target(self): target = extract_tool_context_target( @@ -119,6 +170,80 @@ def test_current_turn_prompt_uses_matching_turn_id(self): self.assertEqual(prompt, "Fix payment tests") + def test_load_tool_context_uses_exact_queries_first(self): + from scripts.lib.tool_context import load_tool_context + + client = FakeClient() + context = load_tool_context( + client, + "u", + "a", + { + "toolName": "Edit", + "kind": "file_edit", + "path": "src/payment/calc.ts", + "projectSlug": "payment-service-abc", + "prompt": "Fix payment tests", + }, + {"toolContextMaxItems": 6, "toolContextMinExactItems": 2}, + ) + + self.assertEqual(len(client.item_queries), 2) + self.assertEqual(len(client.raw_queries), 1) + self.assertEqual(client.item_queries[0]["categories"], ["resolution", "tool", "playbook", "directive"]) + self.assertEqual(client.item_queries[0]["metadata_filter"]["all"][0]["path"], "projectSlug") + self.assertEqual(client.raw_queries[0]["types"], ["agent_timeline"]) + self.assertEqual(context["target"]["path"], "src/payment/calc.ts") + self.assertEqual(context["items"][0]["category"], "resolution") + self.assertEqual(context["rawData"][0]["id"], "rd-1") + + def test_load_tool_context_uses_retrieve_fallback_when_exact_hits_are_sparse(self): + from scripts.lib.tool_context import load_tool_context + + class SparseClient(FakeClient): + def query_items(self, **kwargs): + self.item_queries.append(kwargs) + return SimpleNamespace(items=[]) + + def query_raw_data(self, **kwargs): + self.raw_queries.append(kwargs) + return SimpleNamespace(raw_data=[]) + + def retrieve(self, *args, **kwargs): + self.retrieve_queries.append(kwargs) + return SimpleNamespace( + items=[ + SimpleNamespace( + id="tool-1", + text="Use npm test payment after editing payment calculation files.", + category="tool", + created_at="2026-05-27T10:00:00Z", + metadata={"commands": ["npm test payment"]}, + ) + ], + insights=[], + raw_data=[], + ) + + client = SparseClient() + context = load_tool_context( + client, + "u", + "a", + { + "toolName": "Bash", + "kind": "test_result", + "command": "npm test payment", + "projectSlug": "payment-service-abc", + "prompt": "Fix payment tests", + }, + {"toolContextMaxItems": 6, "toolContextMinExactItems": 1}, + ) + + self.assertEqual(len(client.retrieve_queries), 1) + self.assertEqual(client.retrieve_queries[0]["categories"], ["resolution", "tool", "playbook", "directive"]) + self.assertEqual(context["items"][0]["id"], "tool-1") + if __name__ == "__main__": unittest.main() From 90d675d39098776ac58fab9a65acb6462938ab17 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 15:04:11 +0800 Subject: [PATCH 43/54] feat(agent): compile pre-tool memory context --- .../scripts/lib/context_compiler.py | 99 ++++++++++++++++- .../tests/test_context_compiler.py | 102 ++++++++++++++++++ .../codex/scripts/lib/context_compiler.py | 99 ++++++++++++++++- .../codex/tests/test_context_compiler.py | 102 ++++++++++++++++++ 4 files changed, 400 insertions(+), 2 deletions(-) diff --git a/memind-integrations/claude-code/scripts/lib/context_compiler.py b/memind-integrations/claude-code/scripts/lib/context_compiler.py index 7f5b3741..d4665025 100644 --- a/memind-integrations/claude-code/scripts/lib/context_compiler.py +++ b/memind-integrations/claude-code/scripts/lib/context_compiler.py @@ -64,9 +64,34 @@ "memoryItems": 3, } +TOOL_SECTION_ORDER = [ + ("priorResolutions", "## Prior Resolutions"), + ("validationNotes", "## Validation Notes"), + ("relevantPlaybooks", "## Relevant Playbooks"), + ("directives", "## Directives"), + ("recentEvidence", "## Recent Evidence"), +] + +TOOL_SECTION_BUDGETS = { + "priorResolutions": 900, + "validationNotes": 700, + "relevantPlaybooks": 700, + "directives": 500, + "recentEvidence": 700, +} + +ALL_SECTION_ORDER = SESSION_SECTION_ORDER + PROMPT_SECTION_ORDER + TOOL_SECTION_ORDER + SECTION_FIT_PRIORITY = { "memind_session_context": ["mustFollow", "watchOuts", "continueFrom", "playbooks", "facts"], "memind_memories": ["directives", "resolvedProblems", "playbooks", "toolNotes", "insights", "memoryItems"], + "memind_tool_context": [ + "priorResolutions", + "validationNotes", + "directives", + "relevantPlaybooks", + "recentEvidence", + ], } WATCH_OUT_TERMS = { @@ -165,6 +190,42 @@ def compile_prompt_retrieval_context(data, config): ) +def compile_tool_context(context, config): + target = context.get("target") or {} + items = [_normalize_tool_item(item) for item in context.get("items") or [] if _field(item, "text")] + raw_data = [_normalize_tool_rawdata(raw) for raw in context.get("rawData") or []] + sections = { + "priorResolutions": _top_category(items, "resolution", 2), + "validationNotes": _top_category(items, "tool", 3), + "relevantPlaybooks": _top_category(items, "playbook", 2), + "directives": _top_category(items, "directive", 2), + "recentEvidence": raw_data[:2], + } + + attrs = {"tool": target.get("toolName") or "unknown"} + if target.get("path"): + attrs["file"] = target["path"] + if target.get("command"): + attrs["command"] = target["command"] + if target.get("projectSlug"): + attrs["project"] = target["projectSlug"] + + return _render_context( + wrapper="memind_tool_context", + attrs=attrs, + preamble=( + "Use only if directly relevant to this exact tool call. " + "Current user instructions and repository files take precedence. " + "Verify old details against the working tree before relying on them." + ), + sections=_prepare_sections(sections, "tool_context"), + order=TOOL_SECTION_ORDER, + budgets=TOOL_SECTION_BUDGETS, + max_chars=int(config.get("toolContextMaxChars", 3500)), + entry_max_chars=int(config.get("toolContextEntryMaxChars", 520)), + ) + + def _prepare_sections(sections, mode): prepared = {} high_value_seen = set() @@ -249,6 +310,41 @@ def _normalize_insight(insight): } +def _normalize_tool_item(item): + category = str(_field(item, "category") or "memory").strip().lower() + return { + "kind": "item", + "id": _field(item, "id"), + "category": category, + "text": _clean(_field(item, "text")), + "createdAt": _field(item, "createdAt") or _field(item, "created_at"), + "score": _number(_field(item, "score"), _field(item, "finalScore"), 0), + } + + +def _normalize_tool_rawdata(raw): + text = _field(raw, "caption") or _recent_evidence_from_metadata(_field(raw, "metadata") or {}) + return { + "kind": "rawdata", + "id": _field(raw, "id") or _field(raw, "rawDataId") or _field(raw, "raw_data_id"), + "category": "agent_timeline", + "text": _clean(text), + "createdAt": _field(raw, "createdAt") or _field(raw, "created_at"), + "score": 0, + } + + +def _recent_evidence_from_metadata(metadata): + stats = metadata.get("toolStats") or {} + parts = [] + for tool_name, stat in stats.items(): + success = int(stat.get("successCount") or 0) + failed = int(stat.get("failCount") or 0) + if success or failed: + parts.append(f"{tool_name} failed {failed} time(s) and passed {success} time(s)") + return "; ".join(parts) + + def _rank_entries(entries, section, mode): if section == "continueFrom": return sorted(entries, key=lambda entry: _timestamp(entry.get("createdAt")), reverse=True)[:3] @@ -430,7 +526,8 @@ def _fit_sections_to_total_budget(fixed_lines, rendered_sections, close_tag, max selected_keys.add(key) used += addition - selected_sections.sort(key=lambda item: [key for key, _title in SESSION_SECTION_ORDER + PROMPT_SECTION_ORDER].index(item[0]) if item[0] in {key for key, _title in SESSION_SECTION_ORDER + PROMPT_SECTION_ORDER} else 999) + order_index = {key: index for index, (key, _title) in enumerate(ALL_SECTION_ORDER)} + selected_sections.sort(key=lambda item: order_index.get(item[0], 999)) selected = list(fixed_lines) for _key, section_lines in selected_sections: selected.append("") diff --git a/memind-integrations/claude-code/tests/test_context_compiler.py b/memind-integrations/claude-code/tests/test_context_compiler.py index a4eb5614..574d9683 100644 --- a/memind-integrations/claude-code/tests/test_context_compiler.py +++ b/memind-integrations/claude-code/tests/test_context_compiler.py @@ -217,6 +217,108 @@ def test_prompt_retrieval_context_keeps_degraded_notice(self): self.assertIn("Memory retrieval encountered an error", rendered) self.assertIn("", rendered) + def test_tool_context_compiler_renders_bounded_file_context(self): + from scripts.lib.context_compiler import compile_tool_context + + rendered = compile_tool_context( + { + "target": { + "toolName": "Edit", + "kind": "file_edit", + "path": "src/payment/calc.ts", + "projectSlug": "payment-service-abc", + }, + "items": [ + { + "id": "res-1", + "category": "resolution", + "text": "rounding mismatch was resolved in src/payment/calc.ts and validated with npm test payment.", + "metadata": { + "files": ["src/payment/calc.ts"], + "commands": ["npm test payment"], + }, + }, + { + "id": "tool-1", + "category": "tool", + "text": "Use npm test payment to validate changes touching src/payment/calc.ts; it failed once and passed once in this agent episode.", + "metadata": { + "files": ["src/payment/calc.ts"], + "commands": ["npm test payment"], + "toolStats": {"Bash": {"successCount": 1, "failCount": 1}}, + }, + }, + { + "id": "pb-1", + "category": "playbook", + "text": "When payment calculation logic changes, update focused tests first, then run npm test payment.", + "metadata": {}, + }, + ], + "rawData": [ + { + "id": "rd-1", + "caption": "Edited src/payment/calc.ts and validated npm test payment.", + "metadata": { + "toolStats": {"Bash": {"successCount": 1, "failCount": 1}}, + }, + } + ], + }, + {"toolContextMaxChars": 3500, "toolContextEntryMaxChars": 520}, + ) + + self.assertIn('")) + + def test_tool_context_compiler_renders_command_context_with_budget(self): + from scripts.lib.context_compiler import compile_tool_context + + rendered = compile_tool_context( + { + "target": { + "toolName": "Bash", + "kind": "test_result", + "command": "npm test payment", + "projectSlug": "payment-service-abc", + }, + "items": [ + { + "id": "tool-1", + "category": "tool", + "text": "Use npm test payment after editing payment calculation files. " + "x" * 900, + "metadata": {"commands": ["npm test payment"]}, + }, + { + "id": "dir-1", + "category": "directive", + "text": "Do not skip focused payment validation after touching calculation code.", + "metadata": {}, + }, + ], + "rawData": [], + }, + {"toolContextMaxChars": 900, "toolContextEntryMaxChars": 260}, + ) + + self.assertLessEqual(len(rendered), 900) + self.assertIn('")) + if __name__ == "__main__": unittest.main() diff --git a/memind-integrations/codex/scripts/lib/context_compiler.py b/memind-integrations/codex/scripts/lib/context_compiler.py index 7f5b3741..d4665025 100644 --- a/memind-integrations/codex/scripts/lib/context_compiler.py +++ b/memind-integrations/codex/scripts/lib/context_compiler.py @@ -64,9 +64,34 @@ "memoryItems": 3, } +TOOL_SECTION_ORDER = [ + ("priorResolutions", "## Prior Resolutions"), + ("validationNotes", "## Validation Notes"), + ("relevantPlaybooks", "## Relevant Playbooks"), + ("directives", "## Directives"), + ("recentEvidence", "## Recent Evidence"), +] + +TOOL_SECTION_BUDGETS = { + "priorResolutions": 900, + "validationNotes": 700, + "relevantPlaybooks": 700, + "directives": 500, + "recentEvidence": 700, +} + +ALL_SECTION_ORDER = SESSION_SECTION_ORDER + PROMPT_SECTION_ORDER + TOOL_SECTION_ORDER + SECTION_FIT_PRIORITY = { "memind_session_context": ["mustFollow", "watchOuts", "continueFrom", "playbooks", "facts"], "memind_memories": ["directives", "resolvedProblems", "playbooks", "toolNotes", "insights", "memoryItems"], + "memind_tool_context": [ + "priorResolutions", + "validationNotes", + "directives", + "relevantPlaybooks", + "recentEvidence", + ], } WATCH_OUT_TERMS = { @@ -165,6 +190,42 @@ def compile_prompt_retrieval_context(data, config): ) +def compile_tool_context(context, config): + target = context.get("target") or {} + items = [_normalize_tool_item(item) for item in context.get("items") or [] if _field(item, "text")] + raw_data = [_normalize_tool_rawdata(raw) for raw in context.get("rawData") or []] + sections = { + "priorResolutions": _top_category(items, "resolution", 2), + "validationNotes": _top_category(items, "tool", 3), + "relevantPlaybooks": _top_category(items, "playbook", 2), + "directives": _top_category(items, "directive", 2), + "recentEvidence": raw_data[:2], + } + + attrs = {"tool": target.get("toolName") or "unknown"} + if target.get("path"): + attrs["file"] = target["path"] + if target.get("command"): + attrs["command"] = target["command"] + if target.get("projectSlug"): + attrs["project"] = target["projectSlug"] + + return _render_context( + wrapper="memind_tool_context", + attrs=attrs, + preamble=( + "Use only if directly relevant to this exact tool call. " + "Current user instructions and repository files take precedence. " + "Verify old details against the working tree before relying on them." + ), + sections=_prepare_sections(sections, "tool_context"), + order=TOOL_SECTION_ORDER, + budgets=TOOL_SECTION_BUDGETS, + max_chars=int(config.get("toolContextMaxChars", 3500)), + entry_max_chars=int(config.get("toolContextEntryMaxChars", 520)), + ) + + def _prepare_sections(sections, mode): prepared = {} high_value_seen = set() @@ -249,6 +310,41 @@ def _normalize_insight(insight): } +def _normalize_tool_item(item): + category = str(_field(item, "category") or "memory").strip().lower() + return { + "kind": "item", + "id": _field(item, "id"), + "category": category, + "text": _clean(_field(item, "text")), + "createdAt": _field(item, "createdAt") or _field(item, "created_at"), + "score": _number(_field(item, "score"), _field(item, "finalScore"), 0), + } + + +def _normalize_tool_rawdata(raw): + text = _field(raw, "caption") or _recent_evidence_from_metadata(_field(raw, "metadata") or {}) + return { + "kind": "rawdata", + "id": _field(raw, "id") or _field(raw, "rawDataId") or _field(raw, "raw_data_id"), + "category": "agent_timeline", + "text": _clean(text), + "createdAt": _field(raw, "createdAt") or _field(raw, "created_at"), + "score": 0, + } + + +def _recent_evidence_from_metadata(metadata): + stats = metadata.get("toolStats") or {} + parts = [] + for tool_name, stat in stats.items(): + success = int(stat.get("successCount") or 0) + failed = int(stat.get("failCount") or 0) + if success or failed: + parts.append(f"{tool_name} failed {failed} time(s) and passed {success} time(s)") + return "; ".join(parts) + + def _rank_entries(entries, section, mode): if section == "continueFrom": return sorted(entries, key=lambda entry: _timestamp(entry.get("createdAt")), reverse=True)[:3] @@ -430,7 +526,8 @@ def _fit_sections_to_total_budget(fixed_lines, rendered_sections, close_tag, max selected_keys.add(key) used += addition - selected_sections.sort(key=lambda item: [key for key, _title in SESSION_SECTION_ORDER + PROMPT_SECTION_ORDER].index(item[0]) if item[0] in {key for key, _title in SESSION_SECTION_ORDER + PROMPT_SECTION_ORDER} else 999) + order_index = {key: index for index, (key, _title) in enumerate(ALL_SECTION_ORDER)} + selected_sections.sort(key=lambda item: order_index.get(item[0], 999)) selected = list(fixed_lines) for _key, section_lines in selected_sections: selected.append("") diff --git a/memind-integrations/codex/tests/test_context_compiler.py b/memind-integrations/codex/tests/test_context_compiler.py index 9be0cbb6..d18e5111 100644 --- a/memind-integrations/codex/tests/test_context_compiler.py +++ b/memind-integrations/codex/tests/test_context_compiler.py @@ -223,6 +223,108 @@ def test_prompt_retrieval_context_keeps_degraded_notice(self): self.assertIn("Memory retrieval encountered an error", rendered) self.assertIn("", rendered) + def test_tool_context_compiler_renders_bounded_file_context(self): + from scripts.lib.context_compiler import compile_tool_context + + rendered = compile_tool_context( + { + "target": { + "toolName": "Edit", + "kind": "file_edit", + "path": "src/payment/calc.ts", + "projectSlug": "payment-service-abc", + }, + "items": [ + { + "id": "res-1", + "category": "resolution", + "text": "rounding mismatch was resolved in src/payment/calc.ts and validated with npm test payment.", + "metadata": { + "files": ["src/payment/calc.ts"], + "commands": ["npm test payment"], + }, + }, + { + "id": "tool-1", + "category": "tool", + "text": "Use npm test payment to validate changes touching src/payment/calc.ts; it failed once and passed once in this agent episode.", + "metadata": { + "files": ["src/payment/calc.ts"], + "commands": ["npm test payment"], + "toolStats": {"Bash": {"successCount": 1, "failCount": 1}}, + }, + }, + { + "id": "pb-1", + "category": "playbook", + "text": "When payment calculation logic changes, update focused tests first, then run npm test payment.", + "metadata": {}, + }, + ], + "rawData": [ + { + "id": "rd-1", + "caption": "Edited src/payment/calc.ts and validated npm test payment.", + "metadata": { + "toolStats": {"Bash": {"successCount": 1, "failCount": 1}}, + }, + } + ], + }, + {"toolContextMaxChars": 3500, "toolContextEntryMaxChars": 520}, + ) + + self.assertIn('")) + + def test_tool_context_compiler_renders_command_context_with_budget(self): + from scripts.lib.context_compiler import compile_tool_context + + rendered = compile_tool_context( + { + "target": { + "toolName": "Bash", + "kind": "test_result", + "command": "npm test payment", + "projectSlug": "payment-service-abc", + }, + "items": [ + { + "id": "tool-1", + "category": "tool", + "text": "Use npm test payment after editing payment calculation files. " + "x" * 900, + "metadata": {"commands": ["npm test payment"]}, + }, + { + "id": "dir-1", + "category": "directive", + "text": "Do not skip focused payment validation after touching calculation code.", + "metadata": {}, + }, + ], + "rawData": [], + }, + {"toolContextMaxChars": 900, "toolContextEntryMaxChars": 260}, + ) + + self.assertLessEqual(len(rendered), 900) + self.assertIn('")) + if __name__ == "__main__": unittest.main() From 80526f64fd35c31fa3be16458de73d83d9a6397e Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 15:09:09 +0800 Subject: [PATCH 44/54] feat(agent): inject Claude Code pre-tool context --- .../claude-code/scripts/pre_tool_use.py | 64 ++++++++-- .../claude-code/tests/test_hooks.py | 111 ++++++++++++++++++ 2 files changed, 166 insertions(+), 9 deletions(-) diff --git a/memind-integrations/claude-code/scripts/pre_tool_use.py b/memind-integrations/claude-code/scripts/pre_tool_use.py index d4c2d77d..0b414d96 100644 --- a/memind-integrations/claude-code/scripts/pre_tool_use.py +++ b/memind-integrations/claude-code/scripts/pre_tool_use.py @@ -16,33 +16,79 @@ import json import os import sys +from pathlib import Path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from ingest import state_root from lib.agent_timeline import normalize_hook_event +from lib.client import MemindClient from lib.config import load_config +from lib.context_compiler import compile_tool_context +from lib.identity import project_slug, resolve_identity from lib.logging_utils import debug_log from lib.state import SessionStateStore +from lib.tool_context import ( + current_turn_prompt, + extract_tool_context_target, + load_tool_context, + should_query_tool_context, +) + + +def handle_pre_tool_use(hook_input): + config = load_config() + session_id = hook_input.get("session_id") or "unknown-session" + hook_input["source_client"] = config.get("sourceClient") or "claude-code" + with SessionStateStore(state_root()).locked(session_id) as state: + turn_id, turn_seq = state.ensure_agent_turn(session_id) + seq = state.next_agent_seq() + event = normalize_hook_event(hook_input, seq, turn_id=turn_id, turn_seq=turn_seq) + state.append_agent_event(event) + events = state.agent_events() + + cwd = hook_input.get("cwd") + slug = project_slug(Path(cwd)) if cwd else None + target = extract_tool_context_target(event, hook_input, slug) + target["prompt"] = current_turn_prompt(events, target.get("turnId")) + if not should_query_tool_context(target, config): + return {"continue": True} + + identity = resolve_identity(config, hook_input) + client = MemindClient( + config["memindApiUrl"], + config.get("memindApiToken"), + timeout=2, + max_retries=0, + ) + context_input = load_tool_context( + client, + identity["userId"], + identity["agentId"], + target, + config, + ) + context = compile_tool_context(context_input, config) + if not context: + return {"continue": True} + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "additionalContext": context, + } + } def main(): try: hook_input = json.loads(sys.stdin.read() or "{}") - config = load_config() - session_id = hook_input.get("session_id") or "unknown-session" - hook_input["source_client"] = config.get("sourceClient") or "claude-code" - with SessionStateStore(state_root()).locked(session_id) as state: - turn_id, turn_seq = state.ensure_agent_turn(session_id) - seq = state.next_agent_seq() - event = normalize_hook_event(hook_input, seq, turn_id=turn_id, turn_seq=turn_seq) - state.append_agent_event(event) + print(json.dumps(handle_pre_tool_use(hook_input))) except Exception as exc: try: debug_log(load_config(), "pre_tool_use_failed", {"error": str(exc)}) except Exception: pass - print(json.dumps({"continue": True})) + print(json.dumps({"continue": True})) if __name__ == "__main__": diff --git a/memind-integrations/claude-code/tests/test_hooks.py b/memind-integrations/claude-code/tests/test_hooks.py index 038b2ef8..8f5cfe9b 100644 --- a/memind-integrations/claude-code/tests/test_hooks.py +++ b/memind-integrations/claude-code/tests/test_hooks.py @@ -13,6 +13,7 @@ # import json +import importlib import os import subprocess import sys @@ -162,6 +163,116 @@ def test_pre_tool_use_fails_open(self): self.assertEqual(event["kind"], "test_result") self.assertEqual(event["status"], "running") + def test_pre_tool_use_injects_tool_context_when_memind_returns_matches(self): + sys.path.insert(0, str(ROOT / "scripts")) + import pre_tool_use + from scripts.lib.state import SessionStateStore + + pre_tool_use = importlib.reload(pre_tool_use) + config = { + "memindApiUrl": "http://127.0.0.1:8366", + "memindApiToken": None, + "sourceClient": "claude-code", + "agentId": "coding-agent", + "userId": "u", + "autoRetrieve": True, + "autoToolContext": True, + "toolContextMaxItems": 6, + "toolContextMinExactItems": 1, + "toolContextMaxChars": 3500, + "toolContextEntryMaxChars": 520, + "retrieveStrategy": "SIMPLE", + } + + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + store = SessionStateStore(state_dir) + with store.locked("s1") as state: + turn_id, turn_seq = state.start_agent_turn("s1") + state.append_agent_event( + { + "eventId": "prompt", + "seq": 1, + "kind": "user_prompt", + "text": "Fix payment tests", + "metadata": {"turnId": turn_id, "turnSeq": turn_seq}, + } + ) + + class FakeClient: + def query_items(self, **kwargs): + return types.SimpleNamespace( + items=[ + types.SimpleNamespace( + id="res-1", + text="rounding mismatch was resolved in src/payment/calc.ts and validated with npm test payment.", + category="resolution", + created_at="2026-05-27T10:00:00Z", + metadata={ + "projectSlug": "tmp-project", + "files": ["src/payment/calc.ts"], + }, + ) + ] + ) + + def query_raw_data(self, **kwargs): + return types.SimpleNamespace(raw_data=[]) + + def retrieve(self, *args, **kwargs): + return types.SimpleNamespace(items=[], insights=[], raw_data=[]) + + with mock.patch.object(pre_tool_use, "state_root", return_value=state_dir): + with mock.patch.object(pre_tool_use, "load_config", return_value=config): + with mock.patch.object(pre_tool_use, "resolve_identity", return_value={"userId": "u", "agentId": "coding-agent"}): + with mock.patch.object(pre_tool_use, "MemindClient", return_value=FakeClient()): + output = pre_tool_use.handle_pre_tool_use( + { + "hook_event_name": "PreToolUse", + "cwd": tmp, + "session_id": "s1", + "tool_name": "Edit", + "tool_input": {"file_path": "src/payment/calc.ts"}, + "timestamp": "2026-05-28T10:00:00Z", + } + ) + + self.assertIn("hookSpecificOutput", output) + context = output["hookSpecificOutput"]["additionalContext"] + self.assertIn(" Date: Thu, 28 May 2026 15:11:15 +0800 Subject: [PATCH 45/54] feat(agent): inject Codex pre-tool context --- .../codex/scripts/pre_tool_use.py | 64 ++++++++-- memind-integrations/codex/tests/test_hooks.py | 112 ++++++++++++++++++ 2 files changed, 167 insertions(+), 9 deletions(-) diff --git a/memind-integrations/codex/scripts/pre_tool_use.py b/memind-integrations/codex/scripts/pre_tool_use.py index 93009465..baf35aa5 100644 --- a/memind-integrations/codex/scripts/pre_tool_use.py +++ b/memind-integrations/codex/scripts/pre_tool_use.py @@ -16,33 +16,79 @@ import json import os import sys +from pathlib import Path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from ingest import state_root from lib.agent_timeline import normalize_hook_event +from lib.client import MemindClient from lib.config import load_config +from lib.context_compiler import compile_tool_context +from lib.identity import project_slug, resolve_identity from lib.logging_utils import debug_log from lib.state import SessionStateStore, state_key +from lib.tool_context import ( + current_turn_prompt, + extract_tool_context_target, + load_tool_context, + should_query_tool_context, +) + + +def handle_pre_tool_use(hook_input): + config = load_config() + hook_input["source_client"] = config.get("sourceClient") or "codex" + session_key = state_key(hook_input) + with SessionStateStore(state_root()).locked(session_key) as state: + turn_id, turn_seq = state.ensure_agent_turn(session_key) + seq = state.next_agent_seq() + event = normalize_hook_event(hook_input, seq, turn_id=turn_id, turn_seq=turn_seq) + state.append_agent_event(event) + events = state.agent_events() + + cwd = hook_input.get("cwd") + slug = project_slug(Path(cwd)) if cwd else None + target = extract_tool_context_target(event, hook_input, slug) + target["prompt"] = current_turn_prompt(events, target.get("turnId")) + if not should_query_tool_context(target, config): + return {"continue": True} + + identity = resolve_identity(config, hook_input) + client = MemindClient( + config["memindApiUrl"], + config.get("memindApiToken"), + timeout=2, + max_retries=0, + ) + context_input = load_tool_context( + client, + identity["userId"], + identity["agentId"], + target, + config, + ) + context = compile_tool_context(context_input, config) + if not context: + return {"continue": True} + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "additionalContext": context, + } + } def main(): try: hook_input = json.loads(sys.stdin.read() or "{}") - config = load_config() - hook_input["source_client"] = config.get("sourceClient") or "codex" - session_key = state_key(hook_input) - with SessionStateStore(state_root()).locked(session_key) as state: - turn_id, turn_seq = state.ensure_agent_turn(session_key) - seq = state.next_agent_seq() - event = normalize_hook_event(hook_input, seq, turn_id=turn_id, turn_seq=turn_seq) - state.append_agent_event(event) + print(json.dumps(handle_pre_tool_use(hook_input))) except Exception as exc: try: debug_log(load_config(), "pre_tool_use_failed", {"error": str(exc)}) except Exception: pass - print(json.dumps({"continue": True})) + print(json.dumps({"continue": True})) if __name__ == "__main__": diff --git a/memind-integrations/codex/tests/test_hooks.py b/memind-integrations/codex/tests/test_hooks.py index 668fb3d4..154f850c 100644 --- a/memind-integrations/codex/tests/test_hooks.py +++ b/memind-integrations/codex/tests/test_hooks.py @@ -12,6 +12,7 @@ # limitations under the License. # +import importlib import os import json import subprocess @@ -160,6 +161,117 @@ def test_pre_tool_use_fails_open_and_buffers_event(self): self.assertEqual(event["kind"], "test_result") self.assertEqual(event["status"], "running") + def test_pre_tool_use_injects_tool_context_when_memind_returns_matches(self): + sys.path.insert(0, str(ROOT / "scripts")) + import pre_tool_use + from scripts.lib.state import SessionStateStore, state_key + + pre_tool_use = importlib.reload(pre_tool_use) + config = { + "memindApiUrl": "http://127.0.0.1:8366", + "memindApiToken": None, + "sourceClient": "codex", + "agentId": "coding-agent", + "userId": "u", + "autoRetrieve": True, + "autoToolContext": True, + "toolContextMaxItems": 6, + "toolContextMinExactItems": 1, + "toolContextMaxChars": 3500, + "toolContextEntryMaxChars": 520, + "retrieveStrategy": "SIMPLE", + } + + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + store = SessionStateStore(state_dir) + session_key = state_key({"session_id": "s1"}) + with store.locked(session_key) as state: + turn_id, turn_seq = state.start_agent_turn(session_key) + state.append_agent_event( + { + "eventId": "prompt", + "seq": 1, + "kind": "user_prompt", + "text": "Fix payment tests", + "metadata": {"turnId": turn_id, "turnSeq": turn_seq}, + } + ) + + class FakeClient: + def query_items(self, **kwargs): + return types.SimpleNamespace( + items=[ + types.SimpleNamespace( + id="res-1", + text="rounding mismatch was resolved in src/payment/calc.ts and validated with npm test payment.", + category="resolution", + created_at="2026-05-27T10:00:00Z", + metadata={ + "projectSlug": "tmp-project", + "files": ["src/payment/calc.ts"], + }, + ) + ] + ) + + def query_raw_data(self, **kwargs): + return types.SimpleNamespace(raw_data=[]) + + def retrieve(self, *args, **kwargs): + return types.SimpleNamespace(items=[], insights=[], raw_data=[]) + + with mock.patch.object(pre_tool_use, "state_root", return_value=state_dir): + with mock.patch.object(pre_tool_use, "load_config", return_value=config): + with mock.patch.object(pre_tool_use, "resolve_identity", return_value={"userId": "u", "agentId": "coding-agent"}): + with mock.patch.object(pre_tool_use, "MemindClient", return_value=FakeClient()): + output = pre_tool_use.handle_pre_tool_use( + { + "hook_event_name": "PreToolUse", + "cwd": tmp, + "session_id": "s1", + "tool_name": "Edit", + "tool_input": {"file_path": "src/payment/calc.ts"}, + "timestamp": "2026-05-28T10:00:00Z", + } + ) + + self.assertIn("hookSpecificOutput", output) + context = output["hookSpecificOutput"]["additionalContext"] + self.assertIn(" Date: Thu, 28 May 2026 15:13:40 +0800 Subject: [PATCH 46/54] chore(agent): install pre-tool context helper --- memind-integrations/codex/install.sh | 1 + memind-integrations/codex/tests/test_installer.py | 1 + 2 files changed, 2 insertions(+) diff --git a/memind-integrations/codex/install.sh b/memind-integrations/codex/install.sh index 734d9c53..6f049f31 100644 --- a/memind-integrations/codex/install.sh +++ b/memind-integrations/codex/install.sh @@ -195,6 +195,7 @@ download_remote_install() { "scripts/lib/retry.py" "scripts/lib/session_context.py" "scripts/lib/state.py" + "scripts/lib/tool_context.py" ) mkdir -p "${INSTALL_ROOT}" rm -rf "${INSTALL_ROOT}/scripts" "${INSTALL_ROOT}/hooks" "${INSTALL_ROOT}/.codex-plugin" diff --git a/memind-integrations/codex/tests/test_installer.py b/memind-integrations/codex/tests/test_installer.py index f6b8b57a..23120514 100644 --- a/memind-integrations/codex/tests/test_installer.py +++ b/memind-integrations/codex/tests/test_installer.py @@ -82,6 +82,7 @@ def test_remote_install_file_list_includes_context_compiler(self): install_script = (ROOT / "install.sh").read_text() self.assertIn('"scripts/lib/context_compiler.py"', install_script) + self.assertIn('"scripts/lib/tool_context.py"', install_script) def test_install_merges_and_reinstall_is_idempotent(self): with tempfile.TemporaryDirectory() as tmp: From 8745a1a3d6d783def778862a0f80d0b9af0022c2 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 15:16:43 +0800 Subject: [PATCH 47/54] docs(agent): describe pre-tool context --- .../specs/2026-05-24-rawdata-agent-design.md | 6 +++ memind-integrations/claude-code/README.md | 39 +++++++++++++++++-- memind-integrations/codex/README.md | 38 ++++++++++++++++-- 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-05-24-rawdata-agent-design.md b/docs/superpowers/specs/2026-05-24-rawdata-agent-design.md index 78da8003..eb7d4958 100644 --- a/docs/superpowers/specs/2026-05-24-rawdata-agent-design.md +++ b/docs/superpowers/specs/2026-05-24-rawdata-agent-design.md @@ -1361,6 +1361,12 @@ Recommendation: not required for v1. Use `memory/extract` with `rawContent.type = "agent_timeline"` first. Add endpoint later as a convenience wrapper if client ergonomics demand it. +### PreToolUse Context + +`rawdata-agent` stores enough deterministic file/tool metadata to support a retrieval-time PreToolUse context compiler. +The compiler should use existing `tool`, `resolution`, `playbook`, `directive`, and `agent_episode` data. It must not +change the rawdata storage model, must not run `rawdata-toolcall` extraction, and must not add per-tool LLM calls. + ## Risks and Mitigations ### Risk: Too Much Noise diff --git a/memind-integrations/claude-code/README.md b/memind-integrations/claude-code/README.md index ce2dc2cc..0a223d8a 100644 --- a/memind-integrations/claude-code/README.md +++ b/memind-integrations/claude-code/README.md @@ -104,7 +104,7 @@ The installed hooks are: | --- | --- | ---: | --- | | `SessionStart` | `scripts/session_start.py` | 5s | Health check, replay at most one failed retry payload, clean old state, and inject project continuity context when available. | | `UserPromptSubmit` | `scripts/retrieve.py` | 12s | Buffer the user prompt event and retrieve relevant Memind context. | -| `PreToolUse` | `scripts/pre_tool_use.py` | 5s | Buffer a redacted tool-start event in local session state. | +| `PreToolUse` | `scripts/pre_tool_use.py` | 5s | Buffer a redacted tool-start event and, for high-value file edits or commands, inject compact file/tool memory context. | | `PostToolUse` | `scripts/post_tool_use.py` | 5s | Buffer a redacted tool-result event in local session state. | | `Notification` | `scripts/notification.py` | 5s | Buffer permission, blocking, and other user-visible lifecycle notifications. | | `SubagentStop` | `scripts/subagent_stop.py` | 5s | Buffer subagent completion evidence for later playbook and handoff extraction. | @@ -112,8 +112,9 @@ The installed hooks are: | `Stop` | `scripts/ingest.py` | 15s | Flush buffered `agent_timeline` events after a turn. | | `SessionEnd` | `scripts/session_end.py` | 10s | Flush remaining buffered `agent_timeline` events at session end. | -`Stop`, `PreToolUse`, `PostToolUse`, `Notification`, and `SubagentStop` are configured as async so regular turn -completion stays fast. +`PostToolUse`, `Stop`, `Notification`, and `SubagentStop` are configured as async so regular turn execution is not +blocked by ingestion work. `PreToolUse` is intentionally synchronous because it may inject a small context block before +the tool executes; it still fails open and skips retrieval for low-value tools. ## Configuration @@ -152,10 +153,15 @@ Settings are loaded in this order: | `autoRetrieve` | `true` | Enables prompt-time memory retrieval. | | `autoSessionContext` | `true` | Enables SessionStart project continuity context injection. | | `autoIngestAgentTimeline` | `true` | Enables user prompt, tool/result, assistant message, and stop event buffering plus `agent_timeline` rawdata flush. | +| `autoToolContext` | `true` | Enables compact PreToolUse context for high-value file edits and commands. | | `retrieveStrategy` | `SIMPLE` | Memind retrieval strategy. | | `retrieveMaxEntries` | `8` | Maximum formatted memory entries injected into Claude Code. | | `retrieveMaxChars` | `6000` | Maximum injected context characters. | | `retrieveContextTurns` | `0` | Number of recent transcript turns to include in the retrieval query. | +| `toolContextMaxChars` | `3500` | Maximum injected PreToolUse context characters. | +| `toolContextEntryMaxChars` | `520` | Maximum characters per PreToolUse context entry. | +| `toolContextMaxItems` | `6` | Maximum exact or fallback items considered for PreToolUse context. | +| `toolContextMinExactItems` | `2` | Minimum exact item hits before semantic retrieve fallback is skipped. | | `sessionContextRecentSessions` | `3` | Maximum recent `agent_timeline` captions shown at SessionStart. | | `sessionContextMaxItems` | `6` | Maximum items fetched for each SessionStart context section. | | `sessionContextMaxChars` | `6000` | Maximum SessionStart context characters. | @@ -173,7 +179,9 @@ export MEMIND_USER_ID=local__alice export MEMIND_AGENT_ID=coding-agent export MEMIND_SOURCE_CLIENT=claude-code export MEMIND_AUTO_SESSION_CONTEXT=true +export MEMIND_AUTO_TOOL_CONTEXT=true export MEMIND_AUTO_INGEST_AGENT_TIMELINE=true +export MEMIND_TOOL_CONTEXT_MAX_CHARS=3500 export MEMIND_SESSION_CONTEXT_MAX_CHARS=6000 export MEMIND_RETRIEVE_CONTEXT_TURNS=0 export MEMIND_DEBUG=true @@ -182,7 +190,9 @@ export MEMIND_DEBUG=true Additional environment variables include `MEMIND_AUTO_RETRIEVE`, `MEMIND_RETRIEVE_STRATEGY`, `MEMIND_RETRIEVE_MAX_ENTRIES`, `MEMIND_RETRIEVE_MAX_CHARS`, `MEMIND_STATE_MAX_AGE_DAYS`, `MEMIND_SESSION_CONTEXT_RECENT_SESSIONS`, `MEMIND_SESSION_CONTEXT_MAX_ITEMS`, -`MEMIND_INGEST_RETRY_SPOOL`, `MEMIND_INGEST_RETRY_MAX_FILES`, and `MEMIND_INGEST_RETRY_MAX_AGE_DAYS`. +`MEMIND_TOOL_CONTEXT_ENTRY_MAX_CHARS`, `MEMIND_TOOL_CONTEXT_MAX_ITEMS`, +`MEMIND_TOOL_CONTEXT_MIN_EXACT_ITEMS`, `MEMIND_INGEST_RETRY_SPOOL`, +`MEMIND_INGEST_RETRY_MAX_FILES`, and `MEMIND_INGEST_RETRY_MAX_AGE_DAYS`. ## Identity Model @@ -280,6 +290,27 @@ Agent memory items are grouped separately when returned by Memind: The compiler deduplicates per section, applies section budgets, and preserves the closing XML-style wrapper when the context must be truncated. +## PreToolUse Context + +For high-value tools such as `Edit`, `Write`, `MultiEdit`, and validation `Bash` commands, Memind may inject a compact +tool-specific context block: + +```text + +Use only if directly relevant to this exact tool call. Current user instructions and repository files take precedence. + +## Prior Resolutions +- [item:res-1 resolution] rounding mismatch was resolved in src/payment/calc.ts and validated with npm test payment. + +## Validation Notes +- [item:tool-1 tool] Use npm test payment to validate changes touching src/payment/calc.ts. + +``` + +The context is built from existing Memind items and `agent_episode` metadata. It does not add extra LLM calls and does +not submit duplicate `tool_call` raw data. Setting `autoToolContext` to `false` disables only this PreToolUse context +injection; tool-start events are still buffered into the local `agent_timeline` state for later Stop-time extraction. + ## Ingestion Behavior Ingestion is timeline-only for Claude Code. The plugin does not submit transcript conversation-style raw data. It buffers diff --git a/memind-integrations/codex/README.md b/memind-integrations/codex/README.md index ab18c79b..36c6fba7 100644 --- a/memind-integrations/codex/README.md +++ b/memind-integrations/codex/README.md @@ -122,7 +122,7 @@ The installed hooks are: | --- | --- | ---: | --- | | `SessionStart` | `scripts/session_start.py` | 5s | Replay at most one failed timeline payload, clean old state, and inject project continuity context when available. | | `UserPromptSubmit` | `scripts/retrieve.py` | 12s | Buffer the user prompt event and retrieve relevant Memind context. | -| `PreToolUse` | `scripts/pre_tool_use.py` | 5s | Buffer a redacted tool-start event in local session state. | +| `PreToolUse` | `scripts/pre_tool_use.py` | 5s | Buffer a redacted tool-start event and, for high-value file edits or commands, inject compact file/tool memory context. | | `PostToolUse` | `scripts/post_tool_use.py` | 5s | Buffer a redacted tool-result event in local session state. | | `Stop` | `scripts/ingest.py` | 15s | Flush buffered `agent_timeline` events after a turn. | @@ -130,6 +130,9 @@ Codex currently registers only the hook events listed above. Memind does not sim events such as `PreCompact`, `SessionEnd`, `Notification`, or `SubagentStop` in the Codex adapter. If Codex adds native support for additional lifecycle events, they should be added as explicit hooks with tests. +`PreToolUse` is intentionally synchronous because it may inject a small context block before the tool executes. The hook +fails open and skips retrieval for low-value tools. `PostToolUse` and `Stop` keep their existing ingestion behavior. + ## Configuration The default configuration works with a local Memind server at `http://127.0.0.1:8366`. @@ -166,10 +169,15 @@ Settings are loaded in this order: | `autoRetrieve` | `true` | Enables prompt-time memory retrieval. | | `autoSessionContext` | `true` | Enables SessionStart project continuity context injection. | | `autoIngestAgentTimeline` | `true` | Enables user prompt, tool/result, assistant message, and stop event buffering plus `agent_timeline` rawdata flush. | +| `autoToolContext` | `true` | Enables compact PreToolUse context for high-value file edits and commands. | | `retrieveStrategy` | `SIMPLE` | Memind retrieval strategy. | | `retrieveMaxEntries` | `8` | Maximum formatted memory entries injected into Codex. | | `retrieveMaxChars` | `6000` | Maximum injected context characters. | | `retrieveContextTurns` | `0` | Number of recent transcript turns to include in the retrieval query. | +| `toolContextMaxChars` | `3500` | Maximum injected PreToolUse context characters. | +| `toolContextEntryMaxChars` | `520` | Maximum characters per PreToolUse context entry. | +| `toolContextMaxItems` | `6` | Maximum exact or fallback items considered for PreToolUse context. | +| `toolContextMinExactItems` | `2` | Minimum exact item hits before semantic retrieve fallback is skipped. | | `sessionContextRecentSessions` | `3` | Maximum recent `agent_timeline` captions shown at SessionStart. | | `sessionContextMaxItems` | `6` | Maximum items fetched for each SessionStart context section. | | `sessionContextMaxChars` | `6000` | Maximum SessionStart context characters. | @@ -187,7 +195,9 @@ export MEMIND_USER_ID=local__alice export MEMIND_AGENT_ID=coding-agent export MEMIND_SOURCE_CLIENT=codex export MEMIND_AUTO_SESSION_CONTEXT=true +export MEMIND_AUTO_TOOL_CONTEXT=true export MEMIND_AUTO_INGEST_AGENT_TIMELINE=true +export MEMIND_TOOL_CONTEXT_MAX_CHARS=3500 export MEMIND_SESSION_CONTEXT_MAX_CHARS=6000 export MEMIND_RETRIEVE_CONTEXT_TURNS=0 export MEMIND_DEBUG=true @@ -196,8 +206,9 @@ export MEMIND_DEBUG=true Additional environment variables include `MEMIND_AUTO_RETRIEVE`, `MEMIND_RETRIEVE_STRATEGY`, `MEMIND_RETRIEVE_MAX_ENTRIES`, `MEMIND_RETRIEVE_MAX_CHARS`, `MEMIND_SESSION_CONTEXT_RECENT_SESSIONS`, `MEMIND_SESSION_CONTEXT_MAX_ITEMS`, -`MEMIND_STATE_MAX_AGE_DAYS`, `MEMIND_INGEST_RETRY_SPOOL`, `MEMIND_INGEST_RETRY_MAX_FILES`, -and `MEMIND_INGEST_RETRY_MAX_AGE_DAYS`. +`MEMIND_TOOL_CONTEXT_ENTRY_MAX_CHARS`, `MEMIND_TOOL_CONTEXT_MAX_ITEMS`, +`MEMIND_TOOL_CONTEXT_MIN_EXACT_ITEMS`, `MEMIND_STATE_MAX_AGE_DAYS`, +`MEMIND_INGEST_RETRY_SPOOL`, `MEMIND_INGEST_RETRY_MAX_FILES`, and `MEMIND_INGEST_RETRY_MAX_AGE_DAYS`. ## Identity Model @@ -295,6 +306,27 @@ Agent memory items are grouped separately when returned by Memind: The compiler deduplicates per section, applies section budgets, and preserves the closing XML-style wrapper when the context must be truncated. +## PreToolUse Context + +For high-value tools such as `Edit`, `Write`, `MultiEdit`, and validation shell commands, Memind may inject a compact +tool-specific context block: + +```text + +Use only if directly relevant to this exact tool call. Current user instructions and repository files take precedence. + +## Prior Resolutions +- [item:res-1 resolution] rounding mismatch was resolved in src/payment/calc.ts and validated with npm test payment. + +## Validation Notes +- [item:tool-1 tool] Use npm test payment to validate changes touching src/payment/calc.ts. + +``` + +The context is built from existing Memind items and `agent_episode` metadata. It does not add extra LLM calls and does +not submit duplicate `tool_call` raw data. Setting `autoToolContext` to `false` disables only this PreToolUse context +injection; tool-start events are still buffered into the local `agent_timeline` state for later Stop-time extraction. + ## Ingestion Behavior Ingestion is timeline-only for Codex. The plugin does not submit transcript conversation-style raw data. It buffers one From c667681eb27814dbbc83d7d3989ec5807825a5f0 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 15:18:04 +0800 Subject: [PATCH 48/54] docs(agent): add pre-tool context implementation plan --- .../2026-05-28-agent-pre-tool-use-context.md | 2733 +++++++++++++++++ 1 file changed, 2733 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-28-agent-pre-tool-use-context.md diff --git a/docs/superpowers/plans/2026-05-28-agent-pre-tool-use-context.md b/docs/superpowers/plans/2026-05-28-agent-pre-tool-use-context.md new file mode 100644 index 00000000..b0f35079 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-agent-pre-tool-use-context.md @@ -0,0 +1,2733 @@ +# Agent PreToolUse Context Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Inject compact file/tool-aware Memind context immediately before high-value Claude Code and Codex tool calls. + +**Architecture:** Keep the existing `rawdata-agent` storage and extraction model unchanged. `PreToolUse` continues to buffer normalized tool-start events, then optionally performs a small retrieval against existing `tool`, `resolution`, `playbook`, `directive`, and `agent_episode` data and compiles a bounded `` block. No additional LLM calls, no `rawdata-toolcall` double-ingestion, and no OpenAPI schema changes are required for v1. + +**Tech Stack:** Python integration hooks, Memind Python client, OpenAPI item/rawdata query endpoints, existing metadata filter operators, unittest, JSON hook manifests. + +--- + +## Scope And Non-Goals + +In scope: + +- Add a PreToolUse context path for Claude Code and Codex. +- Keep PreToolUse ingestion behavior: every PreToolUse still appends a normalized `agent_timeline` event to local durable state. +- Query existing Memind data by `userId + agentId`, current project slug, current file path, current command, and current tool name. +- Compile a small context block with: + - `Prior Resolutions` + - `Validation Notes` + - `Relevant Playbooks` + - `Directives` + - `Recent Evidence` +- Use `toolRecords`, `toolStats`, and `toolGroups` only as internal evidence for ranking and concise summaries. +- Default token budget target: roughly 500-800 tokens, with `toolContextMaxChars=3500`. +- Fail open if Memind is unavailable, no high-value target exists, or no useful context is found. + +Out of scope: + +- No `rawdata-agent` Java storage model changes. +- No `rawdata-toolcall` ingestion or LLM extraction inside PreToolUse. +- No new OpenAPI endpoints or metadata-filter operators. +- No per-tool LLM observation extraction. +- No blocking or denying tool execution. +- No injection for low-value read/search/list tools in v1. + +## Key Design Decisions + +1. **PreToolUse is a local, exact-context compiler, not another broad memory retrieval.** + `UserPromptSubmit` already injects task-level memories. PreToolUse should only inject context specific to the imminent file edit or command. + +2. **Claude Code PreToolUse must become synchronous.** + The current Claude Code manifest marks `PreToolUse` as async. An async hook is suitable for telemetry buffering but cannot reliably inject `additionalContext` before the tool executes. This plan removes `async` only from Claude Code `PreToolUse`; `PostToolUse`, `Stop`, `Notification`, and `SubagentStop` stay async. + +3. **Codex already keeps hooks synchronous in the current manifest.** + Codex PreToolUse will call the new context compiler from its existing synchronous hook path, while preserving its + current manifest contract. + +4. **No new Memind core API is required for v1.** + Existing top-level metadata fields (`files`, `commands`, `toolNames`, `projectSlug`) are enough for exact filters. Nested `toolRecords` and `toolStats` are read from returned metadata for evidence summaries, not used as required server-side filters. + +5. **Structured query first, semantic retrieve fallback second.** + Exact `query_items` / `query_raw_data` calls are preferred for file/command/tool matches. A bounded `retrieve` fallback is used only when exact queries do not return enough usable items. + +6. **The context compiler hides raw telemetry.** + `durationMs`, `inputTokens`, and `outputTokens` should not be rendered. `toolStats` and `toolRecords` are summarized as validation evidence only. + +## File Map + +- Modify `memind-integrations/claude-code/hooks/hooks.json` + - Make `PreToolUse` synchronous by removing `"async": true`. + +- Modify `memind-integrations/claude-code/settings.json` + - Add default PreToolUse context settings. + +- Modify `memind-integrations/claude-code/scripts/lib/config.py` + - Add config defaults and env vars for PreToolUse context. + +- Modify `memind-integrations/claude-code/scripts/lib/client.py` + - Extend the local `retrieve(...)` wrapper to pass structured filters and include options. + +- Create `memind-integrations/claude-code/scripts/lib/tool_context.py` + - Extract the tool target, query Memind, rank hits, and return compiler input. + +- Modify `memind-integrations/claude-code/scripts/lib/context_compiler.py` + - Add `compile_tool_context(...)`. + +- Modify `memind-integrations/claude-code/scripts/pre_tool_use.py` + - Buffer the event first, then optionally inject compiled tool context. + +- Modify Claude Code tests: + - `memind-integrations/claude-code/tests/test_config.py` + - `memind-integrations/claude-code/tests/test_client.py` + - `memind-integrations/claude-code/tests/test_context_compiler.py` + - `memind-integrations/claude-code/tests/test_hooks.py` + - `memind-integrations/claude-code/tests/test_manifest.py` + - `memind-integrations/claude-code/tests/test_installer.py` + +- Apply Codex-specific changes with the Codex source client, plugin root, state key, and installer layout: + - `memind-integrations/codex/settings.json` + - `memind-integrations/codex/scripts/lib/config.py` + - `memind-integrations/codex/scripts/lib/client.py` + - `memind-integrations/codex/scripts/lib/tool_context.py` + - `memind-integrations/codex/scripts/lib/context_compiler.py` + - `memind-integrations/codex/scripts/pre_tool_use.py` + - `memind-integrations/codex/tests/*` + - `memind-integrations/codex/install.sh` + +- Modify documentation: + - `memind-integrations/claude-code/README.md` + - `memind-integrations/codex/README.md` + - `docs/superpowers/specs/2026-05-24-rawdata-agent-design.md` + +--- + +### Task 1: Add PreToolUse Context Configuration And Manifest Contract + +**Files:** +- Modify: `memind-integrations/claude-code/settings.json` +- Modify: `memind-integrations/claude-code/scripts/lib/config.py` +- Modify: `memind-integrations/claude-code/hooks/hooks.json` +- Modify: `memind-integrations/claude-code/tests/test_config.py` +- Modify: `memind-integrations/claude-code/tests/test_manifest.py` +- Modify: `memind-integrations/codex/settings.json` +- Modify: `memind-integrations/codex/scripts/lib/config.py` +- Modify: `memind-integrations/codex/tests/test_config.py` +- Modify: `memind-integrations/codex/tests/test_manifest.py` + +- [ ] **Step 1: Add failing Claude Code config assertions** + +Add these assertions to `test_default_settings` in `memind-integrations/claude-code/tests/test_config.py`: + +```python +self.assertTrue(DEFAULT_SETTINGS["autoToolContext"]) +self.assertEqual(DEFAULT_SETTINGS["toolContextMaxChars"], 3500) +self.assertEqual(DEFAULT_SETTINGS["toolContextEntryMaxChars"], 520) +self.assertEqual(DEFAULT_SETTINGS["toolContextMaxItems"], 6) +self.assertEqual(DEFAULT_SETTINGS["toolContextMinExactItems"], 2) +``` + +Add this env override test: + +```python +def test_tool_context_env_overrides(self): + config = load_config( + plugin_root=ROOT, + user_config_path=Path("/no/such/file"), + env={ + "CLAUDE_PLUGIN_ROOT": str(ROOT), + "MEMIND_AUTO_TOOL_CONTEXT": "false", + "MEMIND_TOOL_CONTEXT_MAX_CHARS": "2500", + "MEMIND_TOOL_CONTEXT_ENTRY_MAX_CHARS": "400", + "MEMIND_TOOL_CONTEXT_MAX_ITEMS": "4", + "MEMIND_TOOL_CONTEXT_MIN_EXACT_ITEMS": "1", + }, + ) + + self.assertFalse(config["autoToolContext"]) + self.assertEqual(config["toolContextMaxChars"], 2500) + self.assertEqual(config["toolContextEntryMaxChars"], 400) + self.assertEqual(config["toolContextMaxItems"], 4) + self.assertEqual(config["toolContextMinExactItems"], 1) +``` + +- [ ] **Step 2: Add failing Claude Code manifest assertions** + +In `memind-integrations/claude-code/tests/test_manifest.py`, change the PreToolUse assertion from async to synchronous: + +```python +pre_tool_hook = hooks["PreToolUse"][0]["hooks"][0] +self.assertNotIn("async", pre_tool_hook) +self.assertLessEqual(pre_tool_hook["timeout"], 5) +self.assertTrue(hooks["PostToolUse"][0]["hooks"][0]["async"]) +``` + +Keep the existing assertions for `PostToolUse`, `Notification`, `SubagentStop`, and `Stop` async behavior. + +- [ ] **Step 3: Add Codex config assertions** + +Add these default assertions to `memind-integrations/codex/tests/test_config.py`: + +```python +self.assertTrue(DEFAULT_SETTINGS["autoToolContext"]) +self.assertEqual(DEFAULT_SETTINGS["toolContextMaxChars"], 3500) +self.assertEqual(DEFAULT_SETTINGS["toolContextEntryMaxChars"], 520) +self.assertEqual(DEFAULT_SETTINGS["toolContextMaxItems"], 6) +self.assertEqual(DEFAULT_SETTINGS["toolContextMinExactItems"], 2) +``` + +Add this Codex env override test, using `CODEX_PLUGIN_ROOT`: + +```python +def test_tool_context_env_overrides(self): + config = load_config( + plugin_root=ROOT, + user_config_path=Path("/no/such/file"), + env={ + "CODEX_PLUGIN_ROOT": str(ROOT), + "MEMIND_AUTO_TOOL_CONTEXT": "false", + "MEMIND_TOOL_CONTEXT_MAX_CHARS": "2500", + "MEMIND_TOOL_CONTEXT_ENTRY_MAX_CHARS": "400", + "MEMIND_TOOL_CONTEXT_MAX_ITEMS": "4", + "MEMIND_TOOL_CONTEXT_MIN_EXACT_ITEMS": "1", + }, + ) + + self.assertFalse(config["autoToolContext"]) + self.assertEqual(config["toolContextMaxChars"], 2500) + self.assertEqual(config["toolContextEntryMaxChars"], 400) + self.assertEqual(config["toolContextMaxItems"], 4) + self.assertEqual(config["toolContextMinExactItems"], 1) +``` + +- [ ] **Step 4: Keep Codex manifest synchronous** + +In `memind-integrations/codex/tests/test_manifest.py`, add an explicit assertion: + +```python +self.assertNotIn("async", hooks["PreToolUse"][0]["hooks"][0]) +``` + +- [ ] **Step 5: Run config and manifest tests to verify failure** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_config.py \ + memind-integrations/claude-code/tests/test_manifest.py \ + memind-integrations/codex/tests/test_config.py \ + memind-integrations/codex/tests/test_manifest.py +``` + +Expected: FAIL because the new config keys are missing and Claude Code PreToolUse is still async. + +- [ ] **Step 6: Add settings defaults** + +Add these keys to both `settings.json` files: + +```json +"autoToolContext": true, +"toolContextMaxChars": 3500, +"toolContextEntryMaxChars": 520, +"toolContextMaxItems": 6, +"toolContextMinExactItems": 2, +``` + +Place them near `retrieveContextTurns` so all retrieval-related settings stay together. + +- [ ] **Step 7: Add config defaults and env vars** + +In both `scripts/lib/config.py` files, add to `DEFAULT_SETTINGS`: + +```python +"autoToolContext": True, +"toolContextMaxChars": 3500, +"toolContextEntryMaxChars": 520, +"toolContextMaxItems": 6, +"toolContextMinExactItems": 2, +``` + +Add to `ENV_MAP` in both files: + +```python +"MEMIND_AUTO_TOOL_CONTEXT": ("autoToolContext", "bool"), +"MEMIND_TOOL_CONTEXT_MAX_CHARS": ("toolContextMaxChars", "int"), +"MEMIND_TOOL_CONTEXT_ENTRY_MAX_CHARS": ("toolContextEntryMaxChars", "int"), +"MEMIND_TOOL_CONTEXT_MAX_ITEMS": ("toolContextMaxItems", "int"), +"MEMIND_TOOL_CONTEXT_MIN_EXACT_ITEMS": ("toolContextMinExactItems", "int_allow_zero"), +``` + +- [ ] **Step 8: Make Claude Code PreToolUse synchronous** + +In `memind-integrations/claude-code/hooks/hooks.json`, remove only this line from the `PreToolUse` hook: + +```json +"async": true +``` + +Do not change `PostToolUse`, `Stop`, `Notification`, or `SubagentStop`. + +- [ ] **Step 9: Run tests and verify pass** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_config.py \ + memind-integrations/claude-code/tests/test_manifest.py \ + memind-integrations/codex/tests/test_config.py \ + memind-integrations/codex/tests/test_manifest.py +``` + +Expected: PASS. + +- [ ] **Step 10: Commit** + +```bash +git add \ + memind-integrations/claude-code/settings.json \ + memind-integrations/claude-code/scripts/lib/config.py \ + memind-integrations/claude-code/hooks/hooks.json \ + memind-integrations/claude-code/tests/test_config.py \ + memind-integrations/claude-code/tests/test_manifest.py \ + memind-integrations/codex/settings.json \ + memind-integrations/codex/scripts/lib/config.py \ + memind-integrations/codex/tests/test_config.py \ + memind-integrations/codex/tests/test_manifest.py +git commit -m "feat(agent): configure pre-tool context" +``` + +--- + +### Task 2: Extend Local Client Wrappers For Structured Retrieval + +**Files:** +- Modify: `memind-integrations/claude-code/scripts/lib/client.py` +- Modify: `memind-integrations/claude-code/tests/test_client.py` +- Modify: `memind-integrations/codex/scripts/lib/client.py` +- Modify: `memind-integrations/codex/tests/test_client.py` + +- [ ] **Step 1: Add failing Claude Code structured retrieve wrapper test** + +In `memind-integrations/claude-code/tests/test_client.py`, first extend the fake model helpers near `_MetadataFilter`: + +```python +class _MetadataFilter: + def __init__(self, all=None, any=None, not_=None, **kwargs): + excluded = kwargs.get("not", not_) + self.all = [_MetadataCondition(**item) for item in (all or [])] + self.any = [_MetadataCondition(**item) for item in (any or [])] + self.not_ = [_MetadataCondition(**item) for item in (excluded or [])] + + +class _RetrieveIncludeOptions: + def __init__( + self, + raw_data_metadata=None, + rawDataMetadata=None, + raw_data_segment=None, + rawDataSegment=None, + ): + self.raw_data_metadata = ( + raw_data_metadata if raw_data_metadata is not None else rawDataMetadata + ) + self.raw_data_segment = ( + raw_data_segment if raw_data_segment is not None else rawDataSegment + ) + + +class _TimeRange: + def __init__(self, field=None, from_=None, to=None, **kwargs): + self.field = field + self.from_ = kwargs.get("from", from_) + self.to = to +``` + +Then add these exports to `_fake_memind_module()`: + +```python +module.MetadataFilter = _MetadataFilter +module.RetrieveIncludeOptions = _RetrieveIncludeOptions +module.TimeRange = _TimeRange +``` + +Add this test method to `ClientTest`: + +```python +def test_retrieve_passes_structured_filters(self): + with mock.patch.dict(sys.modules, {"memind": _fake_memind_module()}): + MemindClient = _load_client_class() + client = MemindClient("http://memind", "token", timeout=1, max_retries=0) + result = client.retrieve( + "u", + "a", + "payment context", + "SIMPLE", + False, + scope="AGENT", + categories=["resolution", "tool"], + metadata_filter={ + "all": [{"path": "projectSlug", "op": "eq", "value": "payment"}], + "any": [{"path": "files", "op": "contains", "value": "src/payment/calc.ts"}], + }, + include={"rawDataMetadata": True}, + ) + + self.assertIsNotNone(result) + instance = _FakeSyncMemindClient.instances[0] + retrieve_call = instance.memory.calls[0][1] + self.assertEqual(retrieve_call["scope"], "AGENT") + self.assertEqual(retrieve_call["categories"], ["resolution", "tool"]) + self.assertEqual(retrieve_call["metadata_filter"].all[0].path, "projectSlug") + self.assertEqual(retrieve_call["metadata_filter"].any[0].path, "files") + self.assertTrue(retrieve_call["include"].raw_data_metadata) +``` + +- [ ] **Step 2: Add Codex structured retrieve wrapper test** + +In `memind-integrations/codex/tests/test_client.py`, add these fake model helpers because the Codex wrapper imports +official `memind` Python client types: + +```python +class _MetadataFilter: + def __init__(self, all=None, any=None, not_=None, **kwargs): + excluded = kwargs.get("not", not_) + self.all = [_MetadataCondition(**item) for item in (all or [])] + self.any = [_MetadataCondition(**item) for item in (any or [])] + self.not_ = [_MetadataCondition(**item) for item in (excluded or [])] + + +class _RetrieveIncludeOptions: + def __init__( + self, + raw_data_metadata=None, + rawDataMetadata=None, + raw_data_segment=None, + rawDataSegment=None, + ): + self.raw_data_metadata = ( + raw_data_metadata if raw_data_metadata is not None else rawDataMetadata + ) + self.raw_data_segment = ( + raw_data_segment if raw_data_segment is not None else rawDataSegment + ) + + +class _TimeRange: + def __init__(self, field=None, from_=None, to=None, **kwargs): + self.field = field + self.from_ = kwargs.get("from", from_) + self.to = to +``` + +Export them from the Codex `_fake_memind_module()`: + +```python +module.MetadataFilter = _MetadataFilter +module.RetrieveIncludeOptions = _RetrieveIncludeOptions +module.TimeRange = _TimeRange +``` + +Add this test method to the Codex client test class: + +```python +def test_retrieve_passes_structured_filters(self): + with mock.patch.dict(sys.modules, {"memind": _fake_memind_module()}): + MemindClient = _load_client_class() + client = MemindClient("http://memind", "token", timeout=1, max_retries=0) + result = client.retrieve( + "u", + "a", + "payment context", + "SIMPLE", + False, + scope="AGENT", + categories=["resolution", "tool"], + metadata_filter={ + "all": [{"path": "projectSlug", "op": "eq", "value": "payment"}], + "any": [{"path": "files", "op": "contains", "value": "src/payment/calc.ts"}], + }, + include={"rawDataMetadata": True}, + ) + + self.assertIsNotNone(result) + instance = _FakeSyncMemindClient.instances[0] + retrieve_call = instance.memory.calls[0][1] + self.assertEqual(retrieve_call["scope"], "AGENT") + self.assertEqual(retrieve_call["categories"], ["resolution", "tool"]) + self.assertEqual(retrieve_call["metadata_filter"].all[0].path, "projectSlug") + self.assertEqual(retrieve_call["metadata_filter"].any[0].path, "files") + self.assertTrue(retrieve_call["include"].raw_data_metadata) +``` + +- [ ] **Step 3: Run tests to verify failure** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_client.py \ + memind-integrations/codex/tests/test_client.py +``` + +Expected: FAIL because the local wrapper does not accept structured retrieve parameters yet. + +- [ ] **Step 4: Extend the Claude Code wrapper** + +Change the `retrieve` signature in `memind-integrations/claude-code/scripts/lib/client.py` to: + +```python +def retrieve( + self, + user_id, + agent_id, + query, + strategy="SIMPLE", + trace=False, + scope=None, + categories=None, + time_range=None, + metadata_filter=None, + include=None, +): +``` + +Inside the method import these types: + +```python +from memind import ( + MemindClient as OfficialMemindClient, + MetadataFilter, + RetrieveIncludeOptions, + TimeRange, +) +``` + +Build typed optional objects: + +```python +metadata_filter_obj = ( + MetadataFilter(**metadata_filter) + if isinstance(metadata_filter, dict) + else metadata_filter +) +include_obj = ( + RetrieveIncludeOptions(**include) + if isinstance(include, dict) + else include +) +time_range_obj = TimeRange(**time_range) if isinstance(time_range, dict) else time_range +``` + +Pass them to the official client: + +```python +return client.memory.retrieve( + user_id=user_id, + agent_id=agent_id, + query=query, + strategy=strategy, + trace=trace, + scope=scope, + categories=categories, + time_range=time_range_obj, + metadata_filter=metadata_filter_obj, + include=include_obj, +) +``` + +- [ ] **Step 5: Extend the Codex wrapper** + +Change the `retrieve` signature in `memind-integrations/codex/scripts/lib/client.py` to accept structured retrieval +parameters: + +```python +def retrieve( + self, + user_id, + agent_id, + query, + strategy="SIMPLE", + trace=False, + scope=None, + categories=None, + time_range=None, + metadata_filter=None, + include=None, +): +``` + +Inside the method import these official client model types: + +```python +from memind import ( + MemindClient as OfficialMemindClient, + MetadataFilter, + RetrieveIncludeOptions, + TimeRange, +) +``` + +Convert dict inputs to official typed objects: + +```python +metadata_filter_obj = ( + MetadataFilter(**metadata_filter) + if isinstance(metadata_filter, dict) + else metadata_filter +) +include_obj = ( + RetrieveIncludeOptions(**include) + if isinstance(include, dict) + else include +) +time_range_obj = TimeRange(**time_range) if isinstance(time_range, dict) else time_range +``` + +Pass all optional retrieval controls through to `client.memory.retrieve(...)`: + +```python +return client.memory.retrieve( + user_id=user_id, + agent_id=agent_id, + query=query, + strategy=strategy, + trace=trace, + scope=scope, + categories=categories, + time_range=time_range_obj, + metadata_filter=metadata_filter_obj, + include=include_obj, +) +``` + +- [ ] **Step 6: Run client tests and verify pass** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_client.py \ + memind-integrations/codex/tests/test_client.py +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add \ + memind-integrations/claude-code/scripts/lib/client.py \ + memind-integrations/claude-code/tests/test_client.py \ + memind-integrations/codex/scripts/lib/client.py \ + memind-integrations/codex/tests/test_client.py +git commit -m "feat(agent): support structured retrieve in integrations" +``` + +--- + +### Task 3: Add Tool Context Target Extraction And Query Planning + +**Files:** +- Create: `memind-integrations/claude-code/scripts/lib/tool_context.py` +- Create: `memind-integrations/claude-code/tests/test_tool_context.py` +- Create: `memind-integrations/codex/scripts/lib/tool_context.py` +- Create: `memind-integrations/codex/tests/test_tool_context.py` + +- [ ] **Step 1: Add failing Claude Code target extraction tests** + +Create `memind-integrations/claude-code/tests/test_tool_context.py`: + +```python +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +from scripts.lib.tool_context import ( + build_metadata_filter, + current_turn_prompt, + extract_tool_context_target, + should_query_tool_context, +) + + +class ToolContextTest(unittest.TestCase): + def test_extracts_file_edit_target(self): + target = extract_tool_context_target( + { + "kind": "file_edit", + "toolName": "Edit", + "path": "src/payment/calc.ts", + "operation": "edit", + "metadata": {"turnId": "s-turn-1"}, + }, + {"cwd": "/repo/payment"}, + "payment-service-abc", + ) + + self.assertEqual(target["toolName"], "Edit") + self.assertEqual(target["kind"], "file_edit") + self.assertEqual(target["path"], "src/payment/calc.ts") + self.assertEqual(target["projectSlug"], "payment-service-abc") + + def test_extracts_command_target(self): + target = extract_tool_context_target( + { + "kind": "test_result", + "toolName": "Bash", + "command": "npm test payment", + "operation": "run", + "metadata": {"validationType": "test"}, + }, + {"cwd": "/repo/payment"}, + "payment-service-abc", + ) + + self.assertEqual(target["command"], "npm test payment") + self.assertEqual(target["validationType"], "test") + + def test_skips_low_value_tools(self): + target = extract_tool_context_target( + {"kind": "file_read", "toolName": "Read", "path": "README.md"}, + {"cwd": "/repo/payment"}, + "payment-service-abc", + ) + + self.assertFalse(should_query_tool_context(target, {"autoToolContext": True})) + + def test_skips_when_disabled_or_no_target(self): + self.assertFalse(should_query_tool_context({}, {"autoToolContext": True})) + self.assertFalse( + should_query_tool_context( + {"kind": "file_edit", "path": "src/a.ts"}, {"autoToolContext": False} + ) + ) + + def test_builds_top_level_metadata_filter(self): + metadata_filter = build_metadata_filter( + { + "projectSlug": "payment-service-abc", + "path": "src/payment/calc.ts", + "command": "npm test payment", + "toolName": "Bash", + }, + include_project=True, + ) + + self.assertEqual( + metadata_filter["all"], + [{"path": "projectSlug", "op": "eq", "value": "payment-service-abc"}], + ) + self.assertIn( + {"path": "files", "op": "contains", "value": "src/payment/calc.ts"}, + metadata_filter["any"], + ) + self.assertIn( + {"path": "commands", "op": "contains", "value": "npm test payment"}, + metadata_filter["any"], + ) + self.assertIn( + {"path": "toolNames", "op": "contains", "value": "Bash"}, + metadata_filter["any"], + ) + + def test_current_turn_prompt_uses_matching_turn_id(self): + prompt = current_turn_prompt( + [ + {"kind": "user_prompt", "text": "older", "metadata": {"turnId": "t0"}}, + {"kind": "user_prompt", "text": "Fix payment tests", "metadata": {"turnId": "t1"}}, + {"kind": "file_edit", "metadata": {"turnId": "t1"}}, + ], + "t1", + ) + + self.assertEqual(prompt, "Fix payment tests") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Add failing Codex target extraction tests** + +Create `memind-integrations/codex/tests/test_tool_context.py` with the Codex scripts path and these expected normalized +target behaviors: + +```python +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +from scripts.lib.tool_context import ( + build_metadata_filter, + current_turn_prompt, + extract_tool_context_target, + should_query_tool_context, +) + + +class ToolContextTest(unittest.TestCase): + def test_extracts_file_edit_target(self): + target = extract_tool_context_target( + { + "kind": "file_edit", + "toolName": "Edit", + "path": "src/payment/calc.ts", + "operation": "edit", + "metadata": {"turnId": "s-turn-1"}, + }, + {"cwd": "/repo/payment"}, + "payment-service-abc", + ) + + self.assertEqual(target["toolName"], "Edit") + self.assertEqual(target["kind"], "file_edit") + self.assertEqual(target["path"], "src/payment/calc.ts") + self.assertEqual(target["projectSlug"], "payment-service-abc") + + def test_extracts_command_target(self): + target = extract_tool_context_target( + { + "kind": "test_result", + "toolName": "Bash", + "command": "npm test payment", + "operation": "run", + "metadata": {"validationType": "test"}, + }, + {"cwd": "/repo/payment"}, + "payment-service-abc", + ) + + self.assertEqual(target["command"], "npm test payment") + self.assertEqual(target["validationType"], "test") + + def test_skips_low_value_tools(self): + target = extract_tool_context_target( + {"kind": "file_read", "toolName": "Read", "path": "README.md"}, + {"cwd": "/repo/payment"}, + "payment-service-abc", + ) + + self.assertFalse(should_query_tool_context(target, {"autoToolContext": True})) + + def test_skips_when_disabled_or_no_target(self): + self.assertFalse(should_query_tool_context({}, {"autoToolContext": True})) + self.assertFalse( + should_query_tool_context( + {"kind": "file_edit", "path": "src/a.ts"}, {"autoToolContext": False} + ) + ) + + def test_builds_top_level_metadata_filter(self): + metadata_filter = build_metadata_filter( + { + "projectSlug": "payment-service-abc", + "path": "src/payment/calc.ts", + "command": "npm test payment", + "toolName": "Bash", + }, + include_project=True, + ) + + self.assertEqual( + metadata_filter["all"], + [{"path": "projectSlug", "op": "eq", "value": "payment-service-abc"}], + ) + self.assertIn( + {"path": "files", "op": "contains", "value": "src/payment/calc.ts"}, + metadata_filter["any"], + ) + self.assertIn( + {"path": "commands", "op": "contains", "value": "npm test payment"}, + metadata_filter["any"], + ) + self.assertIn( + {"path": "toolNames", "op": "contains", "value": "Bash"}, + metadata_filter["any"], + ) + + def test_current_turn_prompt_uses_matching_turn_id(self): + prompt = current_turn_prompt( + [ + {"kind": "user_prompt", "text": "older", "metadata": {"turnId": "t0"}}, + {"kind": "user_prompt", "text": "Fix payment tests", "metadata": {"turnId": "t1"}}, + {"kind": "file_edit", "metadata": {"turnId": "t1"}}, + ], + "t1", + ) + + self.assertEqual(prompt, "Fix payment tests") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 3: Run tests to verify failure** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_tool_context.py \ + memind-integrations/codex/tests/test_tool_context.py +``` + +Expected: FAIL because `scripts/lib/tool_context.py` does not exist. + +- [ ] **Step 4: Implement Claude Code target extraction** + +Create `memind-integrations/claude-code/scripts/lib/tool_context.py`: + +```python +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +HIGH_VALUE_KINDS = {"file_edit", "command", "test_result"} + + +def extract_tool_context_target(event, hook_input, project_slug): + metadata = event.get("metadata") or {} + target = { + "toolName": event.get("toolName"), + "kind": event.get("kind"), + "path": event.get("path"), + "command": event.get("command"), + "operation": event.get("operation"), + "validationType": metadata.get("validationType"), + "projectSlug": project_slug, + "cwd": hook_input.get("cwd"), + "turnId": metadata.get("turnId"), + "turnSeq": metadata.get("turnSeq"), + } + return {key: value for key, value in target.items() if value not in (None, "", [])} + + +def should_query_tool_context(target, config): + if not config.get("autoToolContext", True) or not config.get("autoRetrieve", True): + return False + if not target or target.get("kind") not in HIGH_VALUE_KINDS: + return False + return bool(target.get("path") or target.get("command")) + + +def build_metadata_filter(target, include_project=True): + all_conditions = [] + any_conditions = [] + if include_project and target.get("projectSlug"): + all_conditions.append( + {"path": "projectSlug", "op": "eq", "value": target["projectSlug"]} + ) + if target.get("path"): + any_conditions.append({"path": "files", "op": "contains", "value": target["path"]}) + if target.get("command"): + any_conditions.append( + {"path": "commands", "op": "contains", "value": target["command"]} + ) + if target.get("toolName"): + any_conditions.append( + {"path": "toolNames", "op": "contains", "value": target["toolName"]} + ) + return { + "all": all_conditions, + "any": any_conditions, + "not": [], + } + + +def current_turn_prompt(events, turn_id): + if not turn_id: + return "" + for event in reversed(events or []): + metadata = event.get("metadata") or {} + if event.get("kind") == "user_prompt" and metadata.get("turnId") == turn_id: + return event.get("text") or "" + return "" +``` + +- [ ] **Step 5: Implement Codex target extraction** + +Create `memind-integrations/codex/scripts/lib/tool_context.py` with the integration-neutral target schema below, so +downstream retrieval and compilation receive consistent fields across Codex and Claude Code: + +```python +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +HIGH_VALUE_KINDS = {"file_edit", "command", "test_result"} + + +def extract_tool_context_target(event, hook_input, project_slug): + metadata = event.get("metadata") or {} + target = { + "toolName": event.get("toolName"), + "kind": event.get("kind"), + "path": event.get("path"), + "command": event.get("command"), + "operation": event.get("operation"), + "validationType": metadata.get("validationType"), + "projectSlug": project_slug, + "cwd": hook_input.get("cwd"), + "turnId": metadata.get("turnId"), + "turnSeq": metadata.get("turnSeq"), + } + return {key: value for key, value in target.items() if value not in (None, "", [])} + + +def should_query_tool_context(target, config): + if not config.get("autoToolContext", True) or not config.get("autoRetrieve", True): + return False + if not target or target.get("kind") not in HIGH_VALUE_KINDS: + return False + return bool(target.get("path") or target.get("command")) + + +def build_metadata_filter(target, include_project=True): + all_conditions = [] + any_conditions = [] + if include_project and target.get("projectSlug"): + all_conditions.append( + {"path": "projectSlug", "op": "eq", "value": target["projectSlug"]} + ) + if target.get("path"): + any_conditions.append({"path": "files", "op": "contains", "value": target["path"]}) + if target.get("command"): + any_conditions.append( + {"path": "commands", "op": "contains", "value": target["command"]} + ) + if target.get("toolName"): + any_conditions.append( + {"path": "toolNames", "op": "contains", "value": target["toolName"]} + ) + return { + "all": all_conditions, + "any": any_conditions, + "not": [], + } + + +def current_turn_prompt(events, turn_id): + if not turn_id: + return "" + for event in reversed(events or []): + metadata = event.get("metadata") or {} + if event.get("kind") == "user_prompt" and metadata.get("turnId") == turn_id: + return event.get("text") or "" + return "" +``` + +- [ ] **Step 6: Run tests and verify pass** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_tool_context.py \ + memind-integrations/codex/tests/test_tool_context.py +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add \ + memind-integrations/claude-code/scripts/lib/tool_context.py \ + memind-integrations/claude-code/tests/test_tool_context.py \ + memind-integrations/codex/scripts/lib/tool_context.py \ + memind-integrations/codex/tests/test_tool_context.py +git commit -m "feat(agent): extract pre-tool context targets" +``` + +--- + +### Task 4: Add Tool Context Retrieval And Ranking + +**Files:** +- Modify: `memind-integrations/claude-code/scripts/lib/tool_context.py` +- Modify: `memind-integrations/claude-code/tests/test_tool_context.py` +- Modify: `memind-integrations/codex/scripts/lib/tool_context.py` +- Modify: `memind-integrations/codex/tests/test_tool_context.py` + +- [ ] **Step 1: Add failing retrieval test for exact item and rawdata queries** + +In both `test_tool_context.py` files, add `SimpleNamespace` to the imports: + +```python +from types import SimpleNamespace +``` + +Then add this fake client class above `ToolContextTest`: + +```python +class FakeClient: + def __init__(self): + self.item_queries = [] + self.raw_queries = [] + self.retrieve_queries = [] + + def query_items(self, **kwargs): + self.item_queries.append(kwargs) + if kwargs.get("metadata_filter", {}).get("all"): + return SimpleNamespace( + items=[ + SimpleNamespace( + id="res-1", + text="rounding mismatch was resolved in src/payment/calc.ts and validated with npm test payment.", + category="resolution", + created_at="2026-05-27T10:00:00Z", + metadata={ + "projectSlug": "payment-service-abc", + "files": ["src/payment/calc.ts"], + "commands": ["npm test payment"], + }, + ) + ] + ) + return SimpleNamespace(items=[]) + + def query_raw_data(self, **kwargs): + self.raw_queries.append(kwargs) + return SimpleNamespace( + raw_data=[ + SimpleNamespace( + id="rd-1", + caption="Edited src/payment/calc.ts and validated npm test payment.", + type="agent_timeline", + created_at="2026-05-27T10:05:00Z", + metadata={ + "projectSlug": "payment-service-abc", + "files": ["src/payment/calc.ts"], + "commands": ["npm test payment"], + "toolStats": {"Bash": {"successCount": 1, "failCount": 1}}, + }, + ) + ] + ) + + def retrieve(self, *args, **kwargs): + self.retrieve_queries.append(kwargs) + return SimpleNamespace(items=[], insights=[], raw_data=[]) +``` + +Add this method inside `ToolContextTest`: + +```python + +def test_load_tool_context_uses_exact_queries_first(self): + from scripts.lib.tool_context import load_tool_context + + client = FakeClient() + context = load_tool_context( + client, + "u", + "a", + { + "toolName": "Edit", + "kind": "file_edit", + "path": "src/payment/calc.ts", + "projectSlug": "payment-service-abc", + "prompt": "Fix payment tests", + }, + {"toolContextMaxItems": 6, "toolContextMinExactItems": 2}, + ) + + self.assertEqual(len(client.item_queries), 2) + self.assertEqual(len(client.raw_queries), 1) + self.assertEqual(client.item_queries[0]["categories"], ["resolution", "tool", "playbook", "directive"]) + self.assertEqual(client.item_queries[0]["metadata_filter"]["all"][0]["path"], "projectSlug") + self.assertEqual(client.raw_queries[0]["types"], ["agent_timeline"]) + self.assertEqual(context["target"]["path"], "src/payment/calc.ts") + self.assertEqual(context["items"][0]["category"], "resolution") + self.assertEqual(context["rawData"][0]["id"], "rd-1") +``` + +- [ ] **Step 2: Add failing semantic fallback test** + +Add this method inside `ToolContextTest` in both `test_tool_context.py` files: + +```python +def test_load_tool_context_uses_retrieve_fallback_when_exact_hits_are_sparse(self): + from scripts.lib.tool_context import load_tool_context + + class SparseClient(FakeClient): + def query_items(self, **kwargs): + self.item_queries.append(kwargs) + return SimpleNamespace(items=[]) + + def query_raw_data(self, **kwargs): + self.raw_queries.append(kwargs) + return SimpleNamespace(raw_data=[]) + + def retrieve(self, *args, **kwargs): + self.retrieve_queries.append(kwargs) + return SimpleNamespace( + items=[ + SimpleNamespace( + id="tool-1", + text="Use npm test payment after editing payment calculation files.", + category="tool", + created_at="2026-05-27T10:00:00Z", + metadata={"commands": ["npm test payment"]}, + ) + ], + insights=[], + raw_data=[], + ) + + client = SparseClient() + context = load_tool_context( + client, + "u", + "a", + { + "toolName": "Bash", + "kind": "test_result", + "command": "npm test payment", + "projectSlug": "payment-service-abc", + "prompt": "Fix payment tests", + }, + {"toolContextMaxItems": 6, "toolContextMinExactItems": 1}, + ) + + self.assertEqual(len(client.retrieve_queries), 1) + self.assertEqual(client.retrieve_queries[0]["categories"], ["resolution", "tool", "playbook", "directive"]) + self.assertEqual(context["items"][0]["id"], "tool-1") +``` + +- [ ] **Step 3: Run tests to verify failure** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_tool_context.py \ + memind-integrations/codex/tests/test_tool_context.py +``` + +Expected: FAIL because `load_tool_context` does not exist. + +- [ ] **Step 4: Implement query loading in Claude Code** + +Add these functions to `memind-integrations/claude-code/scripts/lib/tool_context.py`: + +```python +TOOL_CONTEXT_CATEGORIES = ["resolution", "tool", "playbook", "directive"] + + +def load_tool_context(client, user_id, agent_id, target, config): + max_items = int(config.get("toolContextMaxItems", 6)) + min_exact = int(config.get("toolContextMinExactItems", 2)) + exact_items = [] + + for include_project in [True, False]: + metadata_filter = build_metadata_filter(target, include_project=include_project) + if not metadata_filter["any"]: + continue + response = client.query_items( + user_id=user_id, + agent_id=agent_id, + scope=None, + categories=TOOL_CONTEXT_CATEGORIES, + source_clients=None, + raw_data_types=["agent_timeline"], + metadata_filter=metadata_filter, + limit=max(10, max_items * 3), + ) + exact_items.extend(_normalize_items(getattr(response, "items", []) or [])) + if len(exact_items) >= max_items: + break + + raw_data = [] + metadata_filter = build_metadata_filter(target, include_project=bool(target.get("projectSlug"))) + if metadata_filter["any"]: + response = client.query_raw_data( + user_id=user_id, + agent_id=agent_id, + types=["agent_timeline"], + source_clients=None, + metadata_filter=metadata_filter, + include={"metadata": True, "segment": False}, + limit=max(6, max_items), + ) + raw_data = _normalize_raw_data(getattr(response, "raw_data", []) or []) + + fallback_items = [] + if len(exact_items) < min_exact: + retrieve_response = client.retrieve( + user_id, + agent_id, + _semantic_query(target), + config.get("retrieveStrategy", "SIMPLE"), + False, + scope=None, + categories=TOOL_CONTEXT_CATEGORIES, + metadata_filter=( + {"all": [{"path": "projectSlug", "op": "eq", "value": target["projectSlug"]}]} + if target.get("projectSlug") + else None + ), + include={"rawDataMetadata": True}, + ) + fallback_items = _normalize_items(getattr(retrieve_response, "items", []) or []) + + items = _dedupe_by_id(exact_items + fallback_items) + return { + "target": dict(target), + "items": rank_items(items, target)[:max_items], + "rawData": rank_raw_data(raw_data, target)[:max_items], + } + + +def _semantic_query(target): + parts = [] + if target.get("prompt"): + parts.append("task: " + target["prompt"]) + if target.get("path"): + parts.append("file: " + target["path"]) + if target.get("command"): + parts.append("command: " + target["command"]) + if target.get("toolName"): + parts.append("tool: " + target["toolName"]) + return "\n".join(parts) or "coding agent tool context" + + +def _normalize_items(items): + result = [] + for item in items: + result.append( + { + "id": _field(item, "id"), + "text": _field(item, "text"), + "category": str(_field(item, "category") or "memory").lower(), + "createdAt": _field(item, "createdAt") or _field(item, "created_at"), + "metadata": _field(item, "metadata") or {}, + } + ) + return [item for item in result if item.get("text")] + + +def _normalize_raw_data(raw_data): + result = [] + for raw in raw_data: + result.append( + { + "id": _field(raw, "rawDataId") or _field(raw, "raw_data_id") or _field(raw, "id"), + "caption": _field(raw, "caption"), + "type": _field(raw, "type"), + "createdAt": _field(raw, "createdAt") or _field(raw, "created_at"), + "metadata": _field(raw, "metadata") or {}, + } + ) + return [raw for raw in result if raw.get("caption") or raw.get("metadata")] + + +def rank_items(items, target): + return sorted( + items, + key=lambda item: ( + _match_score(item.get("metadata") or {}, target), + _category_score(item.get("category")), + item.get("createdAt") or "", + item.get("id") or "", + ), + reverse=True, + ) + + +def rank_raw_data(raw_data, target): + return sorted( + raw_data, + key=lambda raw: ( + _match_score(raw.get("metadata") or {}, target), + raw.get("createdAt") or "", + raw.get("id") or "", + ), + reverse=True, + ) + + +def _match_score(metadata, target): + score = 0 + if target.get("projectSlug") and metadata.get("projectSlug") == target["projectSlug"]: + score += 3 + if target.get("path") and target["path"] in metadata.get("files", []): + score += 10 + if target.get("command") and target["command"] in metadata.get("commands", []): + score += 8 + if target.get("toolName") and target["toolName"] in metadata.get("toolNames", []): + score += 3 + stats = metadata.get("toolStats") or {} + if target.get("toolName") in stats: + tool_stats = stats[target["toolName"]] + score += int(tool_stats.get("successCount") or 0) + return score + + +def _category_score(category): + return {"resolution": 5, "tool": 4, "playbook": 3, "directive": 2}.get(category or "", 1) + + +def _dedupe_by_id(items): + result = [] + seen = set() + for item in items: + key = item.get("id") or item.get("text") + if key in seen: + continue + seen.add(key) + result.append(item) + return result + + +def _field(value, name): + if isinstance(value, dict): + return value.get(name) + return getattr(value, name, None) +``` + +- [ ] **Step 5: Implement Codex query loading** + +Add the retrieval, normalization, ranking, and helper functions below to +`memind-integrations/codex/scripts/lib/tool_context.py`. They use the shared top-level metadata contract: +`projectSlug`, `files`, `commands`, and `toolNames`. + +```python +TOOL_CONTEXT_CATEGORIES = ["resolution", "tool", "playbook", "directive"] + + +def load_tool_context(client, user_id, agent_id, target, config): + max_items = int(config.get("toolContextMaxItems", 6)) + min_exact = int(config.get("toolContextMinExactItems", 2)) + exact_items = [] + + for include_project in [True, False]: + metadata_filter = build_metadata_filter(target, include_project=include_project) + if not metadata_filter["any"]: + continue + response = client.query_items( + user_id=user_id, + agent_id=agent_id, + scope=None, + categories=TOOL_CONTEXT_CATEGORIES, + source_clients=None, + raw_data_types=["agent_timeline"], + metadata_filter=metadata_filter, + limit=max(10, max_items * 3), + ) + exact_items.extend(_normalize_items(getattr(response, "items", []) or [])) + if len(exact_items) >= max_items: + break + + raw_data = [] + metadata_filter = build_metadata_filter(target, include_project=bool(target.get("projectSlug"))) + if metadata_filter["any"]: + response = client.query_raw_data( + user_id=user_id, + agent_id=agent_id, + types=["agent_timeline"], + source_clients=None, + metadata_filter=metadata_filter, + include={"metadata": True, "segment": False}, + limit=max(6, max_items), + ) + raw_data = _normalize_raw_data(getattr(response, "raw_data", []) or []) + + fallback_items = [] + if len(exact_items) < min_exact: + retrieve_response = client.retrieve( + user_id, + agent_id, + _semantic_query(target), + config.get("retrieveStrategy", "SIMPLE"), + False, + scope=None, + categories=TOOL_CONTEXT_CATEGORIES, + metadata_filter=( + {"all": [{"path": "projectSlug", "op": "eq", "value": target["projectSlug"]}]} + if target.get("projectSlug") + else None + ), + include={"rawDataMetadata": True}, + ) + fallback_items = _normalize_items(getattr(retrieve_response, "items", []) or []) + + items = _dedupe_by_id(exact_items + fallback_items) + return { + "target": dict(target), + "items": rank_items(items, target)[:max_items], + "rawData": rank_raw_data(raw_data, target)[:max_items], + } + + +def _semantic_query(target): + parts = [] + if target.get("prompt"): + parts.append("task: " + target["prompt"]) + if target.get("path"): + parts.append("file: " + target["path"]) + if target.get("command"): + parts.append("command: " + target["command"]) + if target.get("toolName"): + parts.append("tool: " + target["toolName"]) + return "\n".join(parts) or "coding agent tool context" + + +def _normalize_items(items): + result = [] + for item in items: + result.append( + { + "id": _field(item, "id"), + "text": _field(item, "text"), + "category": str(_field(item, "category") or "memory").lower(), + "createdAt": _field(item, "createdAt") or _field(item, "created_at"), + "metadata": _field(item, "metadata") or {}, + } + ) + return [item for item in result if item.get("text")] + + +def _normalize_raw_data(raw_data): + result = [] + for raw in raw_data: + result.append( + { + "id": _field(raw, "rawDataId") or _field(raw, "raw_data_id") or _field(raw, "id"), + "caption": _field(raw, "caption"), + "type": _field(raw, "type"), + "createdAt": _field(raw, "createdAt") or _field(raw, "created_at"), + "metadata": _field(raw, "metadata") or {}, + } + ) + return [raw for raw in result if raw.get("caption") or raw.get("metadata")] + + +def rank_items(items, target): + return sorted( + items, + key=lambda item: ( + _match_score(item.get("metadata") or {}, target), + _category_score(item.get("category")), + item.get("createdAt") or "", + item.get("id") or "", + ), + reverse=True, + ) + + +def rank_raw_data(raw_data, target): + return sorted( + raw_data, + key=lambda raw: ( + _match_score(raw.get("metadata") or {}, target), + raw.get("createdAt") or "", + raw.get("id") or "", + ), + reverse=True, + ) + + +def _match_score(metadata, target): + score = 0 + if target.get("projectSlug") and metadata.get("projectSlug") == target["projectSlug"]: + score += 3 + if target.get("path") and target["path"] in metadata.get("files", []): + score += 10 + if target.get("command") and target["command"] in metadata.get("commands", []): + score += 8 + if target.get("toolName") and target["toolName"] in metadata.get("toolNames", []): + score += 3 + stats = metadata.get("toolStats") or {} + if target.get("toolName") in stats: + tool_stats = stats[target["toolName"]] + score += int(tool_stats.get("successCount") or 0) + return score + + +def _category_score(category): + return {"resolution": 5, "tool": 4, "playbook": 3, "directive": 2}.get(category or "", 1) + + +def _dedupe_by_id(items): + result = [] + seen = set() + for item in items: + key = item.get("id") or item.get("text") + if key in seen: + continue + seen.add(key) + result.append(item) + return result + + +def _field(value, name): + if isinstance(value, dict): + return value.get(name) + return getattr(value, name, None) +``` + +- [ ] **Step 6: Run tests and verify pass** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_tool_context.py \ + memind-integrations/codex/tests/test_tool_context.py +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add \ + memind-integrations/claude-code/scripts/lib/tool_context.py \ + memind-integrations/claude-code/tests/test_tool_context.py \ + memind-integrations/codex/scripts/lib/tool_context.py \ + memind-integrations/codex/tests/test_tool_context.py +git commit -m "feat(agent): query pre-tool memory context" +``` + +--- + +### Task 5: Add The PreToolUse Context Compiler + +**Files:** +- Modify: `memind-integrations/claude-code/scripts/lib/context_compiler.py` +- Modify: `memind-integrations/claude-code/tests/test_context_compiler.py` +- Modify: `memind-integrations/codex/scripts/lib/context_compiler.py` +- Modify: `memind-integrations/codex/tests/test_context_compiler.py` + +- [ ] **Step 1: Add failing compiler test for file edit context** + +Append to both `test_context_compiler.py` files: + +```python +def test_tool_context_compiler_renders_bounded_file_context(self): + from scripts.lib.context_compiler import compile_tool_context + + rendered = compile_tool_context( + { + "target": { + "toolName": "Edit", + "kind": "file_edit", + "path": "src/payment/calc.ts", + "projectSlug": "payment-service-abc", + }, + "items": [ + { + "id": "res-1", + "category": "resolution", + "text": "rounding mismatch was resolved in src/payment/calc.ts and validated with npm test payment.", + "metadata": { + "files": ["src/payment/calc.ts"], + "commands": ["npm test payment"], + }, + }, + { + "id": "tool-1", + "category": "tool", + "text": "Use npm test payment to validate changes touching src/payment/calc.ts; it failed once and passed once in this agent episode.", + "metadata": { + "files": ["src/payment/calc.ts"], + "commands": ["npm test payment"], + "toolStats": {"Bash": {"successCount": 1, "failCount": 1}}, + }, + }, + { + "id": "pb-1", + "category": "playbook", + "text": "When payment calculation logic changes, update focused tests first, then run npm test payment.", + "metadata": {}, + }, + ], + "rawData": [ + { + "id": "rd-1", + "caption": "Edited src/payment/calc.ts and validated npm test payment.", + "metadata": { + "toolStats": {"Bash": {"successCount": 1, "failCount": 1}}, + }, + } + ], + }, + {"toolContextMaxChars": 3500, "toolContextEntryMaxChars": 520}, + ) + + self.assertIn('")) +``` + +- [ ] **Step 2: Add failing compiler test for command context and truncation** + +Append to both `test_context_compiler.py` files: + +```python +def test_tool_context_compiler_renders_command_context_with_budget(self): + from scripts.lib.context_compiler import compile_tool_context + + rendered = compile_tool_context( + { + "target": { + "toolName": "Bash", + "kind": "test_result", + "command": "npm test payment", + "projectSlug": "payment-service-abc", + }, + "items": [ + { + "id": "tool-1", + "category": "tool", + "text": "Use npm test payment after editing payment calculation files. " + "x" * 900, + "metadata": {"commands": ["npm test payment"]}, + }, + { + "id": "dir-1", + "category": "directive", + "text": "Do not skip focused payment validation after touching calculation code.", + "metadata": {}, + }, + ], + "rawData": [], + }, + {"toolContextMaxChars": 900, "toolContextEntryMaxChars": 260}, + ) + + self.assertLessEqual(len(rendered), 900) + self.assertIn('")) +``` + +- [ ] **Step 3: Run compiler tests to verify failure** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_context_compiler.py \ + memind-integrations/codex/tests/test_context_compiler.py +``` + +Expected: FAIL because `compile_tool_context` does not exist. + +- [ ] **Step 4: Add compiler constants and entrypoint** + +In both `scripts/lib/context_compiler.py` files, add after `PROMPT_SECTION_LIMITS`: + +```python +TOOL_SECTION_ORDER = [ + ("priorResolutions", "## Prior Resolutions"), + ("validationNotes", "## Validation Notes"), + ("relevantPlaybooks", "## Relevant Playbooks"), + ("directives", "## Directives"), + ("recentEvidence", "## Recent Evidence"), +] + +TOOL_SECTION_BUDGETS = { + "priorResolutions": 900, + "validationNotes": 700, + "relevantPlaybooks": 700, + "directives": 500, + "recentEvidence": 700, +} +``` + +Update `SECTION_FIT_PRIORITY`: + +```python +"memind_tool_context": [ + "priorResolutions", + "validationNotes", + "directives", + "relevantPlaybooks", + "recentEvidence", +], +``` + +Add this function before `_prepare_sections`: + +```python +def compile_tool_context(context, config): + target = context.get("target") or {} + items = [_normalize_tool_item(item) for item in context.get("items") or [] if _field(item, "text")] + raw_data = [_normalize_tool_rawdata(raw) for raw in context.get("rawData") or []] + sections = { + "priorResolutions": _top_category(items, "resolution", 2), + "validationNotes": _top_category(items, "tool", 3), + "relevantPlaybooks": _top_category(items, "playbook", 2), + "directives": _top_category(items, "directive", 2), + "recentEvidence": raw_data[:2], + } + + attrs = {"tool": target.get("toolName") or "unknown"} + if target.get("path"): + attrs["file"] = target["path"] + if target.get("command"): + attrs["command"] = target["command"] + if target.get("projectSlug"): + attrs["project"] = target["projectSlug"] + + return _render_context( + wrapper="memind_tool_context", + attrs=attrs, + preamble=( + "Use only if directly relevant to this exact tool call. " + "Current user instructions and repository files take precedence. " + "Verify old details against the working tree before relying on them." + ), + sections=_prepare_sections(sections, "tool_context"), + order=TOOL_SECTION_ORDER, + budgets=TOOL_SECTION_BUDGETS, + max_chars=int(config.get("toolContextMaxChars", 3500)), + entry_max_chars=int(config.get("toolContextEntryMaxChars", 520)), + ) +``` + +- [ ] **Step 5: Keep total-budget fitting order stable for tool context** + +In both `context_compiler.py` files, add this constant after `TOOL_SECTION_ORDER`: + +```python +ALL_SECTION_ORDER = SESSION_SECTION_ORDER + PROMPT_SECTION_ORDER + TOOL_SECTION_ORDER +``` + +In `_fit_sections_to_total_budget(...)`, replace the final `selected_sections.sort(...)` line with: + +```python +order_index = {key: index for index, (key, _title) in enumerate(ALL_SECTION_ORDER)} +selected_sections.sort(key=lambda item: order_index.get(item[0], 999)) +``` + +This keeps `` section ordering stable even when `toolContextMaxChars` forces lower-priority +sections to be omitted. + +- [ ] **Step 6: Add tool item/rawdata normalizers** + +Add these helpers before `_rank_entries` in both `context_compiler.py` files: + +```python +def _normalize_tool_item(item): + category = str(_field(item, "category") or "memory").strip().lower() + return { + "kind": "item", + "id": _field(item, "id"), + "category": category, + "text": _clean(_field(item, "text")), + "createdAt": _field(item, "createdAt") or _field(item, "created_at"), + "score": _number(_field(item, "score"), _field(item, "finalScore"), 0), + } + + +def _normalize_tool_rawdata(raw): + text = _field(raw, "caption") or _recent_evidence_from_metadata(_field(raw, "metadata") or {}) + return { + "kind": "rawdata", + "id": _field(raw, "id") or _field(raw, "rawDataId") or _field(raw, "raw_data_id"), + "category": "agent_timeline", + "text": _clean(text), + "createdAt": _field(raw, "createdAt") or _field(raw, "created_at"), + "score": 0, + } + + +def _recent_evidence_from_metadata(metadata): + stats = metadata.get("toolStats") or {} + parts = [] + for tool_name, stat in stats.items(): + success = int(stat.get("successCount") or 0) + failed = int(stat.get("failCount") or 0) + if success or failed: + parts.append(f"{tool_name} failed {failed} time(s) and passed {success} time(s)") + return "; ".join(parts) +``` + +Do not render token counts or durations. + +- [ ] **Step 7: Run compiler tests and verify pass** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_context_compiler.py \ + memind-integrations/codex/tests/test_context_compiler.py +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add \ + memind-integrations/claude-code/scripts/lib/context_compiler.py \ + memind-integrations/claude-code/tests/test_context_compiler.py \ + memind-integrations/codex/scripts/lib/context_compiler.py \ + memind-integrations/codex/tests/test_context_compiler.py +git commit -m "feat(agent): compile pre-tool memory context" +``` + +--- + +### Task 6: Wire Claude Code PreToolUse Injection + +**Files:** +- Modify: `memind-integrations/claude-code/scripts/pre_tool_use.py` +- Modify: `memind-integrations/claude-code/tests/test_hooks.py` + +- [ ] **Step 1: Add failing Claude Code hook injection test** + +In `memind-integrations/claude-code/tests/test_hooks.py`, add: + +```python +def test_pre_tool_use_injects_tool_context_when_memind_returns_matches(self): + sys.path.insert(0, str(ROOT / "scripts")) + import pre_tool_use + from scripts.lib.state import SessionStateStore + + config = { + "memindApiUrl": "http://127.0.0.1:8366", + "memindApiToken": None, + "sourceClient": "claude-code", + "agentId": "coding-agent", + "userId": "u", + "autoRetrieve": True, + "autoToolContext": True, + "toolContextMaxItems": 6, + "toolContextMinExactItems": 1, + "toolContextMaxChars": 3500, + "toolContextEntryMaxChars": 520, + "retrieveStrategy": "SIMPLE", + } + + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + store = SessionStateStore(state_dir) + with store.locked("s1") as state: + turn_id, turn_seq = state.start_agent_turn("s1") + state.append_agent_event( + { + "eventId": "prompt", + "seq": 1, + "kind": "user_prompt", + "text": "Fix payment tests", + "metadata": {"turnId": turn_id, "turnSeq": turn_seq}, + } + ) + + class FakeClient: + def query_items(self, **kwargs): + return types.SimpleNamespace( + items=[ + types.SimpleNamespace( + id="res-1", + text="rounding mismatch was resolved in src/payment/calc.ts and validated with npm test payment.", + category="resolution", + created_at="2026-05-27T10:00:00Z", + metadata={ + "projectSlug": "tmp-project", + "files": ["src/payment/calc.ts"], + }, + ) + ] + ) + + def query_raw_data(self, **kwargs): + return types.SimpleNamespace(raw_data=[]) + + def retrieve(self, *args, **kwargs): + return types.SimpleNamespace(items=[], insights=[], raw_data=[]) + + with mock.patch.object(pre_tool_use, "state_root", return_value=state_dir): + with mock.patch.object(pre_tool_use, "load_config", return_value=config): + with mock.patch.object(pre_tool_use, "resolve_identity", return_value={"userId": "u", "agentId": "coding-agent"}): + with mock.patch.object(pre_tool_use, "MemindClient", return_value=FakeClient()): + output = pre_tool_use.handle_pre_tool_use( + { + "hook_event_name": "PreToolUse", + "cwd": tmp, + "session_id": "s1", + "tool_name": "Edit", + "tool_input": {"file_path": "src/payment/calc.ts"}, + "timestamp": "2026-05-28T10:00:00Z", + } + ) + + self.assertIn("hookSpecificOutput", output) + context = output["hookSpecificOutput"]["additionalContext"] + self.assertIn(" +Use only if directly relevant to this exact tool call. Current user instructions and repository files take precedence. + +## Prior Resolutions +- [item:res-1 resolution] rounding mismatch was resolved in src/payment/calc.ts and validated with npm test payment. + +## Validation Notes +- [item:tool-1 tool] Use npm test payment to validate changes touching src/payment/calc.ts. + +``` + +The context is built from existing Memind items and `agent_episode` metadata. It does not add extra LLM calls and does +not submit duplicate `tool_call` raw data. +```` + +Document settings: + +```markdown +| `autoToolContext` | `true` | Enable compact PreToolUse context for high-value file edits and commands. | +| `toolContextMaxChars` | `3500` | Maximum injected PreToolUse context characters. | +| `toolContextEntryMaxChars` | `520` | Maximum characters per PreToolUse context entry. | +| `toolContextMaxItems` | `6` | Maximum exact or fallback items considered for PreToolUse context. | +| `toolContextMinExactItems` | `2` | Minimum exact item hits before semantic retrieve fallback is skipped. | +``` + +- [ ] **Step 2: Update Codex README** + +In the Codex hook table, change `PreToolUse` description to: + +```markdown +| `PreToolUse` | `scripts/pre_tool_use.py` | 5s | Buffer a redacted tool-start event and, for high-value file edits or commands, inject compact file/tool memory context. | +``` + +Add or update the synchronous hook note: + +```markdown +`PreToolUse` is intentionally synchronous because it may inject a small context block before the tool executes. The hook +fails open and skips retrieval for low-value tools. `PostToolUse` and `Stop` keep their existing ingestion behavior. +``` + +Add this section after the Codex retrieval behavior section: + +````markdown +### PreToolUse Context + +For high-value tools such as `Edit`, `Write`, `MultiEdit`, and validation shell commands, Memind may inject a compact +tool-specific context block: + +```text + +Use only if directly relevant to this exact tool call. Current user instructions and repository files take precedence. + +## Prior Resolutions +- [item:res-1 resolution] rounding mismatch was resolved in src/payment/calc.ts and validated with npm test payment. + +## Validation Notes +- [item:tool-1 tool] Use npm test payment to validate changes touching src/payment/calc.ts. + +``` + +The context is built from existing Memind items and `agent_episode` metadata. It does not add extra LLM calls and does +not submit duplicate `tool_call` raw data. +```` + +Document the Codex settings: + +```markdown +| `autoToolContext` | `true` | Enable compact PreToolUse context for high-value file edits and commands. | +| `toolContextMaxChars` | `3500` | Maximum injected PreToolUse context characters. | +| `toolContextEntryMaxChars` | `520` | Maximum characters per PreToolUse context entry. | +| `toolContextMaxItems` | `6` | Maximum exact or fallback items considered for PreToolUse context. | +| `toolContextMinExactItems` | `2` | Minimum exact item hits before semantic retrieve fallback is skipped. | +``` + +- [ ] **Step 3: Update the rawdata-agent design spec** + +In `docs/superpowers/specs/2026-05-24-rawdata-agent-design.md`, add a short section: + +```markdown +### PreToolUse Context + +`rawdata-agent` stores enough deterministic file/tool metadata to support a retrieval-time PreToolUse context compiler. +The compiler should use existing `tool`, `resolution`, `playbook`, `directive`, and `agent_episode` data. It must not +change the rawdata storage model, must not run `rawdata-toolcall` extraction, and must not add per-tool LLM calls. +``` + +- [ ] **Step 4: Commit** + +```bash +git add \ + memind-integrations/claude-code/README.md \ + memind-integrations/codex/README.md \ + docs/superpowers/specs/2026-05-24-rawdata-agent-design.md +git commit -m "docs(agent): describe pre-tool context" +``` + +--- + +### Task 10: Full Verification + +**Files:** +- No source changes unless verification exposes a required fix. + +- [ ] **Step 1: Run Claude Code integration tests** + +Run: + +```bash +python3 -m unittest discover -s memind-integrations/claude-code/tests +``` + +Expected: PASS. + +- [ ] **Step 2: Run Codex integration tests** + +Run: + +```bash +/opt/homebrew/bin/python3.12 -m unittest discover -s memind-integrations/codex/tests +``` + +Expected: PASS. + +- [ ] **Step 3: Run focused timeline tests** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_agent_timeline.py \ + memind-integrations/codex/tests/test_agent_timeline.py +``` + +Expected: PASS. This confirms the PreToolUse event-buffering behavior still works with the existing `agent_timeline` flow. + +- [ ] **Step 4: Check whitespace** + +Run: + +```bash +git diff --check +``` + +Expected: no output. + +- [ ] **Step 5: Inspect hook manifest diff** + +Run: + +```bash +git diff -- memind-integrations/claude-code/hooks/hooks.json memind-integrations/codex/hooks/hooks.json +``` + +Expected: + +- Claude Code `PreToolUse` no longer has `"async": true`. +- Claude Code `PostToolUse`, `Stop`, `Notification`, and `SubagentStop` still have `"async": true`. +- Codex remains synchronous and still has only `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `Stop`. + +- [ ] **Step 6: Inspect rawdata-agent and rawdata-toolcall diffs** + +Run: + +```bash +git diff -- memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-toolcall +``` + +Expected: no Java plugin changes for this plan. + +- [ ] **Step 7: Commit verification-only fixes if needed** + +If formatting or docs fixes were required: + +```bash +git add +git commit -m "chore(agent): finalize pre-tool context" +``` + +- [ ] **Step 8: Push branch** + +Run: + +```bash +git push origin feat/rawdata-agent-memory +``` + +Expected: branch pushed successfully. + +--- + +## Acceptance Checklist + +- [ ] Claude Code and Codex still buffer PreToolUse events into the same local `agent_timeline` state. +- [ ] Claude Code PreToolUse is synchronous only because it may inject context. +- [ ] PostToolUse/Stop ingestion paths remain async where they were async before. +- [ ] PreToolUse skips low-value read/search/list tools. +- [ ] PreToolUse fails open when Memind is unavailable. +- [ ] PreToolUse exact retrieval uses top-level metadata fields: `projectSlug`, `files`, `commands`, `toolNames`. +- [ ] PreToolUse fallback retrieval is bounded and category-filtered. +- [ ] The injected context is wrapped in ``. +- [ ] Injected context defaults to about 500-800 tokens and is hard-capped by `toolContextMaxChars`. +- [ ] Injected context does not render raw `durationMs`, `inputTokens`, or `outputTokens`. +- [ ] No rawdata-agent storage changes are required. +- [ ] No rawdata-toolcall changes are required. +- [ ] No new LLM calls are added. +- [ ] Existing UserPromptSubmit and SessionStart context compilers keep their current behavior. + +## Self-Review Notes + +- Spec coverage: The plan implements PreToolUse context from existing storage, exact metadata retrieval, semantic fallback, context compilation, Claude Code/Codex wiring, settings, docs, installers, and verification. +- Placeholder scan: No TODO/TBD placeholders are present. Code snippets define the functions and assertions needed by later steps. +- Type consistency: The plan consistently uses `autoToolContext`, `toolContextMaxChars`, `toolContextEntryMaxChars`, `toolContextMaxItems`, `toolContextMinExactItems`, `compile_tool_context`, `load_tool_context`, and ``. +- Scope check: This is a retrieval-time integration feature. It deliberately avoids Memind core schema/API changes and rawdata-agent storage changes. From 1c970d1b31ee5ea6ebfd3d20f55545a02b79320a Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 16:56:33 +0800 Subject: [PATCH 49/54] feat(agent): make prompt context project-first --- .../2026-05-28-agent-prompt-context-policy.md | 1552 +++++++++++++++++ memind-integrations/claude-code/README.md | 59 +- .../claude-code/scripts/lib/config.py | 17 + .../scripts/lib/context_compiler.py | 22 +- .../claude-code/scripts/lib/prompt_context.py | 214 +++ .../claude-code/scripts/retrieve.py | 64 +- memind-integrations/claude-code/settings.json | 4 + .../claude-code/tests/test_config.py | 22 + .../tests/test_context_compiler.py | 47 + .../claude-code/tests/test_hooks.py | 99 ++ .../claude-code/tests/test_manifest.py | 4 + .../claude-code/tests/test_prompt_context.py | 358 ++++ memind-integrations/codex/README.md | 59 +- memind-integrations/codex/install.sh | 1 + .../codex/scripts/lib/config.py | 17 + .../codex/scripts/lib/context_compiler.py | 22 +- .../codex/scripts/lib/prompt_context.py | 214 +++ memind-integrations/codex/scripts/retrieve.py | 64 +- memind-integrations/codex/settings.json | 4 + .../codex/tests/test_config.py | 23 + .../codex/tests/test_context_compiler.py | 47 + memind-integrations/codex/tests/test_hooks.py | 99 ++ .../codex/tests/test_installer.py | 1 + .../codex/tests/test_manifest.py | 4 + .../codex/tests/test_prompt_context.py | 358 ++++ 25 files changed, 3283 insertions(+), 92 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-28-agent-prompt-context-policy.md create mode 100644 memind-integrations/claude-code/scripts/lib/prompt_context.py create mode 100644 memind-integrations/claude-code/tests/test_prompt_context.py create mode 100644 memind-integrations/codex/scripts/lib/prompt_context.py create mode 100644 memind-integrations/codex/tests/test_prompt_context.py diff --git a/docs/superpowers/plans/2026-05-28-agent-prompt-context-policy.md b/docs/superpowers/plans/2026-05-28-agent-prompt-context-policy.md new file mode 100644 index 00000000..735c4736 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-agent-prompt-context-policy.md @@ -0,0 +1,1552 @@ +# Agent Prompt Context Policy Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make prompt-time memory injection opt-in by default, and when enabled, retrieve with project-first ranking plus global fallback instead of broad unfiltered recall. + +**Architecture:** Keep Memind core, OpenAPI, rawdata-agent storage, SessionStart context, PreToolUse context, and Stop-time extraction unchanged. Add an integration-local `autoPromptContext` switch for `UserPromptSubmit`; `UserPromptSubmit` always buffers the prompt event, but only retrieves and injects `` when this switch is enabled. The enabled path performs a project-filtered retrieve first, then a bounded unfiltered fallback for reusable cross-project/global memories, dedupes and labels source provenance before rendering. + +**Tech Stack:** Python hook integrations for Claude Code and Codex, Memind Python client, existing retrieve metadata filters, unittest, JSON hook settings, Markdown integration docs. + +--- + +## Scope And Non-Goals + +In scope: + +- Add `autoPromptContext=false` to Claude Code and Codex default settings. +- Add `MEMIND_AUTO_PROMPT_CONTEXT` env override. +- Keep `autoRetrieve` as a backward-compatible broad retrieval gate for now, but stop using it as the only prompt-injection switch. +- Ensure `UserPromptSubmit` still appends a normalized `user_prompt` event even when prompt context is disabled. +- Add project-first prompt retrieval using current `metadata.projectSlug`. +- Add bounded global fallback using the same `userId + agentId` memory space without forcing `projectSlug == currentProject`. +- Merge project and fallback results with stable dedupe and project-first precedence. +- Add source labels to rendered prompt memories: `project`, `global`, or `shared`. +- Update Claude Code and Codex docs so users understand prompt-time context is opt-in and separate from SessionStart/PreToolUse. + +Out of scope: + +- No Memind core changes. +- No OpenAPI schema changes. +- No new server-side metadata operators. +- No new `contextLevel` or context-scope field on items. +- No per-prompt LLM gate. +- No per-tool LLM extraction. +- No change to rawdata-agent item extraction. +- No removal of `autoRetrieve` in this plan; it remains a compatibility gate because existing PreToolUse code still checks it. + +## Current Behavior + +Claude Code and Codex both currently do this in `scripts/retrieve.py`: + +1. Read `UserPromptSubmit` hook JSON. +2. Append a `user_prompt` event to local durable state. +3. If `autoRetrieve` is false, return `{"continue": true}`. +4. Build a query from the prompt plus optional recent transcript turns. +5. Call `client.retrieve(userId, agentId, query, strategy, false)` without project metadata filtering. +6. Render `` and inject it into the next model turn. + +This makes prompt-time injection default-on and broad across the whole shared `userId + agentId` memory space. That is useful when precise, but it can spend tokens every turn and can surface memories from unrelated projects. + +## Target Behavior + +Claude Code and Codex `UserPromptSubmit` should behave like this: + +```text +UserPromptSubmit + -> append USER_PROMPT event to local timeline state + -> if autoRetrieve=false: return continue + -> if autoPromptContext=false: return continue + -> build prompt query + -> resolve projectSlug from cwd + -> retrieve current-project memories first + -> if project hits are sparse, retrieve unfiltered fallback memories + -> dedupe project + fallback results + -> render + -> inject additionalContext +``` + +Default install behavior: + +```json +{ + "autoPromptContext": false, + "autoSessionContext": true, + "autoToolContext": true, + "autoIngestAgentTimeline": true +} +``` + +Enabled prompt context behavior: + +- Project retrieve uses `metadataFilter.all = [{ "path": "projectSlug", "op": "eq", "value": currentProjectSlug }]`. +- Global fallback uses no project filter, but only fills remaining budget. +- Current-project results always beat fallback duplicates. +- Fallback entries are retained only when they are sufficiently relevant or clearly reusable. +- Rendered context explicitly marks provenance so the agent can treat old cross-project information cautiously. + +## File Map + +Claude Code: + +- Modify `memind-integrations/claude-code/settings.json` + - Add prompt context settings with default disabled. + +- Modify `memind-integrations/claude-code/scripts/lib/config.py` + - Add defaults and env overrides. + +- Create `memind-integrations/claude-code/scripts/lib/prompt_context.py` + - Build project-first and fallback retrieve calls. + - Convert Memind response objects to dictionaries. + - Mark provenance and dedupe. + +- Modify `memind-integrations/claude-code/scripts/retrieve.py` + - Keep buffering behavior. + - Check `autoPromptContext`. + - Use `build_prompt_context(...)`. + +- Modify `memind-integrations/claude-code/scripts/lib/context_compiler.py` + - Render source labels and wrapper attrs for prompt context. + +- Modify tests: + - `memind-integrations/claude-code/tests/test_config.py` + - `memind-integrations/claude-code/tests/test_manifest.py` + - `memind-integrations/claude-code/tests/test_context_compiler.py` + - `memind-integrations/claude-code/tests/test_hooks.py` + - Create `memind-integrations/claude-code/tests/test_prompt_context.py` + +- Modify docs: + - `memind-integrations/claude-code/README.md` + +Codex: + +- Modify `memind-integrations/codex/settings.json` +- Modify `memind-integrations/codex/scripts/lib/config.py` +- Create `memind-integrations/codex/scripts/lib/prompt_context.py` +- Modify `memind-integrations/codex/scripts/retrieve.py` +- Modify `memind-integrations/codex/scripts/lib/context_compiler.py` +- Modify tests: + - `memind-integrations/codex/tests/test_config.py` + - `memind-integrations/codex/tests/test_manifest.py` + - `memind-integrations/codex/tests/test_context_compiler.py` + - `memind-integrations/codex/tests/test_hooks.py` + - Create `memind-integrations/codex/tests/test_prompt_context.py` +- Modify docs: + - `memind-integrations/codex/README.md` + +## Config Contract + +Add these settings to both integrations: + +```json +{ + "autoPromptContext": false, + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65 +} +``` + +Add these env vars to both `ENV_MAP` dictionaries: + +```python +"MEMIND_AUTO_PROMPT_CONTEXT": ("autoPromptContext", "bool"), +"MEMIND_PROMPT_CONTEXT_PROJECT_MIN_ENTRIES": ("promptContextProjectMinEntries", "int_allow_zero"), +"MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_ENTRIES": ("promptContextGlobalFallbackEntries", "int_allow_zero"), +"MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_MIN_SCORE": ("promptContextGlobalFallbackMinScore", "float_allow_zero"), +``` + +Add float parsing support: + +```python +def parse_float(value, name, allow_zero=False): + parsed = float(value) + if parsed < 0 or (parsed == 0 and not allow_zero): + raise ValueError(f"{name} must be positive") + return parsed +``` + +Extend `_coerce(...)`: + +```python +if kind == "float_allow_zero": + return parse_float(value, name, allow_zero=True) +``` + +`autoRetrieve` remains supported: + +- `autoRetrieve=false` disables prompt retrieve and tool retrieve fallback, as today. +- `autoPromptContext=false` disables only UserPromptSubmit context injection. +- `autoToolContext=false` disables only PreToolUse context injection. +- `autoSessionContext=false` disables only SessionStart context injection. + +## Prompt Context Helper Contract + +Create `scripts/lib/prompt_context.py` in both integrations. + +Public function: + +```python +def build_prompt_context(client, identity, query, project_slug, config): + ... +``` + +Input: + +- `client`: existing local `MemindClient`. +- `identity`: `{"userId": "...", "agentId": "..."}`. +- `query`: prompt plus optional recent context. +- `project_slug`: result of `lib.identity.project_slug(cwd)`. +- `config`: loaded integration config. + +Output: + +Dictionary suitable for `compile_prompt_retrieval_context(...)`: + +```python +{ + "projectSlug": project_slug, + "mode": "project-first", + "items": [...], + "insights": [...], + "rawData": [...], +} +``` + +Each item/insight should carry a local provenance field: + +```python +{ + "id": "it-1", + "text": "Use metadata.projectSlug for project isolation.", + "category": "directive", + "metadata": {"projectSlug": "memind-a1b2c3"}, + "finalScore": 0.91, + "memindContextSource": "project" +} +``` + +Allowed `memindContextSource` values: + +- `project`: `metadata.projectSlug == currentProjectSlug` +- `global`: no `metadata.projectSlug` +- `shared`: `metadata.projectSlug` exists and differs from current project + +Fallback retrieve should not be called when project retrieve already returns enough usable entries. + +Implementation detail: + +- Always call `_dump_response()` first and then `_mark_sources(...)` on dumped dictionaries. +- Do not attach `memindContextSource` to Python client Pydantic model instances before dump; `MemindModel` ignores extra fields, so the provenance label can be lost during `model_dump()`. +- Pass `default_source="project"` when marking the project-filtered retrieve response. Current `RetrievedInsight` responses do not carry metadata, so project-pass insights must inherit provenance from the retrieve pass rather than from entry metadata. + +## Dedupe And Ranking Rules + +Use a stable key: + +```python +def _entry_key(entry): + entry_id = _field(entry, "id") + if entry_id: + return f"id:{entry_id}" + text = " ".join(str(_field(entry, "text") or "").lower().split()) + return f"text:{text[:260]}" +``` + +Merge order: + +1. Project items. +2. Project insights. +3. Fallback items. +4. Fallback insights. + +When duplicate keys exist, keep the first entry. This guarantees project entries win over fallback duplicates. + +Fallback score filter: + +- Always drop fallback entries whose source is `project`; they are duplicates or project memories already covered by pass one. +- If a fallback entry has `finalScore`, `final_score`, `vectorScore`, `vector_score`, or `score`, keep it only when the score is at least `promptContextGlobalFallbackMinScore`. +- If a fallback insight has no score field, keep it and rely on Memind's returned order plus `promptContextGlobalFallbackEntries`. +- Do not treat missing insight scores as `0`; the Python client `RetrievedInsight` model does not currently expose score fields, so score-gating missing-score insights would drop valid fallback insights. +- Items should normally have scores, but the implementation should use the same score-present check so model/client shape differences do not accidentally remove all fallback data. + +Fallback size: + +- `promptContextGlobalFallbackEntries` applies separately to items and insights after filtering and dedupe. +- Default `3` keeps fallback compact. + +Project hit count: + +- Count usable project `items + insights`. +- If count >= `promptContextProjectMinEntries`, skip fallback. +- Default `4`. + +## Rendered Context Contract + +Update `compile_prompt_retrieval_context(...)` to read these optional fields: + +- `projectSlug` +- `mode` +- `memindContextSource` + +Wrapper: + +```xml + +``` + +Entry labels: + +```text +- [item:dir-1 directive, project, 2026-05-27] Keep userId and agentId stable. +- [item:profile-1 behavior, global, 2026-05-20] User prefers Chinese replies. +- [item:tool-9 tool, shared, 2026-05-18] After hook changes, run both Claude Code and Codex tests. +``` + +Use `shared` instead of another project's slug in the label to avoid leaking unrelated local repo names into the prompt context. The actual `metadata.projectSlug` stays in memory metadata, but not in the injected text. + +Preamble should remain cautious: + +```text +Relevant memories from Memind. Use only when directly helpful: +``` + +No new in-app explanatory text should be inserted beyond the existing preamble and labels. + +--- + +## Task 1: Add Prompt Context Config Defaults + +**Files:** + +- Modify `memind-integrations/claude-code/settings.json` +- Modify `memind-integrations/codex/settings.json` +- Modify `memind-integrations/claude-code/scripts/lib/config.py` +- Modify `memind-integrations/codex/scripts/lib/config.py` +- Test `memind-integrations/claude-code/tests/test_config.py` +- Test `memind-integrations/codex/tests/test_config.py` +- Test `memind-integrations/claude-code/tests/test_manifest.py` +- Test `memind-integrations/codex/tests/test_manifest.py` + +- [ ] **Step 1: Write failing Claude Code config tests** + +Add assertions to `memind-integrations/claude-code/tests/test_config.py`: + +```python +def test_prompt_context_defaults_are_opt_in(self): + from scripts.lib.config import DEFAULT_SETTINGS + + self.assertFalse(DEFAULT_SETTINGS["autoPromptContext"]) + self.assertEqual(DEFAULT_SETTINGS["promptContextProjectMinEntries"], 4) + self.assertEqual(DEFAULT_SETTINGS["promptContextGlobalFallbackEntries"], 3) + self.assertEqual(DEFAULT_SETTINGS["promptContextGlobalFallbackMinScore"], 0.65) + + +def test_prompt_context_env_overrides(self): + from scripts.lib.config import load_config + + config = load_config( + plugin_root=ROOT, + user_config_path=Path("/no/such/file"), + env={ + "CLAUDE_PLUGIN_ROOT": str(ROOT), + "MEMIND_AUTO_PROMPT_CONTEXT": "true", + "MEMIND_PROMPT_CONTEXT_PROJECT_MIN_ENTRIES": "2", + "MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_ENTRIES": "1", + "MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_MIN_SCORE": "0.5", + }, + ) + + self.assertTrue(config["autoPromptContext"]) + self.assertEqual(config["promptContextProjectMinEntries"], 2) + self.assertEqual(config["promptContextGlobalFallbackEntries"], 1) + self.assertEqual(config["promptContextGlobalFallbackMinScore"], 0.5) +``` + +- [ ] **Step 2: Write failing Codex config tests** + +Add equivalent assertions to `memind-integrations/codex/tests/test_config.py`, using `CODEX_PLUGIN_ROOT` where the existing tests use it: + +```python +def test_prompt_context_defaults_are_opt_in(self): + from scripts.lib.config import DEFAULT_SETTINGS + + self.assertFalse(DEFAULT_SETTINGS["autoPromptContext"]) + self.assertEqual(DEFAULT_SETTINGS["promptContextProjectMinEntries"], 4) + self.assertEqual(DEFAULT_SETTINGS["promptContextGlobalFallbackEntries"], 3) + self.assertEqual(DEFAULT_SETTINGS["promptContextGlobalFallbackMinScore"], 0.65) + + +def test_prompt_context_env_overrides(self): + from scripts.lib.config import load_config + + root = Path(__file__).resolve().parents[1] + config = load_config( + plugin_root=root, + user_config_path=Path("/no/such/file"), + env={ + "CODEX_PLUGIN_ROOT": str(root), + "MEMIND_AUTO_PROMPT_CONTEXT": "true", + "MEMIND_PROMPT_CONTEXT_PROJECT_MIN_ENTRIES": "2", + "MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_ENTRIES": "1", + "MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_MIN_SCORE": "0.5", + }, + ) + + self.assertTrue(config["autoPromptContext"]) + self.assertEqual(config["promptContextProjectMinEntries"], 2) + self.assertEqual(config["promptContextGlobalFallbackEntries"], 1) + self.assertEqual(config["promptContextGlobalFallbackMinScore"], 0.5) +``` + +- [ ] **Step 3: Run config tests and verify they fail** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_config.py \ + memind-integrations/codex/tests/test_config.py +``` + +Expected: failures because `autoPromptContext` and float parsing do not exist yet. + +- [ ] **Step 4: Implement config defaults** + +In both `scripts/lib/config.py`, add to `DEFAULT_SETTINGS` after `autoRetrieve`: + +```python +"autoPromptContext": False, +``` + +Add after `retrieveContextTurns` or near prompt retrieval options: + +```python +"promptContextProjectMinEntries": 4, +"promptContextGlobalFallbackEntries": 3, +"promptContextGlobalFallbackMinScore": 0.65, +``` + +Add to `ENV_MAP` after `MEMIND_AUTO_RETRIEVE`: + +```python +"MEMIND_AUTO_PROMPT_CONTEXT": ("autoPromptContext", "bool"), +``` + +Add near retrieval env vars: + +```python +"MEMIND_PROMPT_CONTEXT_PROJECT_MIN_ENTRIES": ("promptContextProjectMinEntries", "int_allow_zero"), +"MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_ENTRIES": ("promptContextGlobalFallbackEntries", "int_allow_zero"), +"MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_MIN_SCORE": ("promptContextGlobalFallbackMinScore", "float_allow_zero"), +``` + +Add float parsing: + +```python +def parse_float(value, name, allow_zero=False): + parsed = float(value) + if parsed < 0 or (parsed == 0 and not allow_zero): + raise ValueError(f"{name} must be positive") + return parsed +``` + +Add to `_coerce(...)`: + +```python +if kind == "float_allow_zero": + return parse_float(value, name, allow_zero=True) +``` + +- [ ] **Step 5: Update default settings JSON** + +Add these keys to both `settings.json` files: + +```json +"autoPromptContext": false, +"promptContextProjectMinEntries": 4, +"promptContextGlobalFallbackEntries": 3, +"promptContextGlobalFallbackMinScore": 0.65, +``` + +Keep `autoRetrieve` unchanged in this task. + +- [ ] **Step 6: Update manifest/default settings tests** + +In both manifest tests, assert that bundled settings include: + +```python +self.assertFalse(settings["autoPromptContext"]) +self.assertEqual(settings["promptContextProjectMinEntries"], 4) +self.assertEqual(settings["promptContextGlobalFallbackEntries"], 3) +self.assertEqual(settings["promptContextGlobalFallbackMinScore"], 0.65) +``` + +- [ ] **Step 7: Run config and manifest tests** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_config.py \ + memind-integrations/claude-code/tests/test_manifest.py \ + memind-integrations/codex/tests/test_config.py \ + memind-integrations/codex/tests/test_manifest.py +``` + +Expected: all pass. + +- [ ] **Step 8: Commit** + +```bash +git add \ + memind-integrations/claude-code/settings.json \ + memind-integrations/claude-code/scripts/lib/config.py \ + memind-integrations/claude-code/tests/test_config.py \ + memind-integrations/claude-code/tests/test_manifest.py \ + memind-integrations/codex/settings.json \ + memind-integrations/codex/scripts/lib/config.py \ + memind-integrations/codex/tests/test_config.py \ + memind-integrations/codex/tests/test_manifest.py +git commit -m "feat(agent): make prompt context opt-in" +``` + +## Task 2: Add Project-First Prompt Retrieval Helper + +**Files:** + +- Create `memind-integrations/claude-code/scripts/lib/prompt_context.py` +- Create `memind-integrations/codex/scripts/lib/prompt_context.py` +- Test `memind-integrations/claude-code/tests/test_prompt_context.py` +- Test `memind-integrations/codex/tests/test_prompt_context.py` + +- [ ] **Step 1: Write failing Claude Code prompt context tests** + +Create `memind-integrations/claude-code/tests/test_prompt_context.py`: + +```python +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts")) + + +class PromptContextTests(unittest.TestCase): + def test_project_hits_skip_global_fallback(self): + from scripts.lib.prompt_context import build_prompt_context + + class FakeResponse: + def __init__(self, items=None, insights=None): + self.items = items or [] + self.insights = insights or [] + self.raw_data = [] + + def model_dump(self, by_alias=True): + return { + "items": self.items, + "insights": self.insights, + "rawData": self.raw_data, + } + + class FakeClient: + def __init__(self): + self.calls = [] + + def retrieve(self, *args, **kwargs): + self.calls.append(kwargs) + return FakeResponse( + items=[ + {"id": "p1", "text": "Project directive", "category": "directive", "metadata": {"projectSlug": "memind-main"}, "finalScore": 0.9}, + {"id": "p2", "text": "Project resolution", "category": "resolution", "metadata": {"projectSlug": "memind-main"}, "finalScore": 0.8}, + ], + insights=[ + {"id": "i1", "text": "Project insight", "tier": "root"}, + {"id": "i2", "text": "Project branch", "tier": "branch"}, + ], + ) + + client = FakeClient() + result = build_prompt_context( + client, + {"userId": "u", "agentId": "a"}, + "fix retrieval", + "memind-main", + { + "retrieveStrategy": "SIMPLE", + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65, + }, + ) + + self.assertEqual(len(client.calls), 1) + self.assertEqual(client.calls[0]["metadata_filter"]["all"][0]["path"], "projectSlug") + self.assertEqual(client.calls[0]["metadata_filter"]["all"][0]["value"], "memind-main") + self.assertEqual(result["mode"], "project-first") + self.assertEqual(result["projectSlug"], "memind-main") + self.assertTrue(all(item["memindContextSource"] == "project" for item in result["items"])) + self.assertTrue(all(insight["memindContextSource"] == "project" for insight in result["insights"])) + + def test_sparse_project_hits_use_bounded_global_fallback(self): + from scripts.lib.prompt_context import build_prompt_context + + class FakeResponse: + def __init__(self, items=None, insights=None): + self.items = items or [] + self.insights = insights or [] + self.raw_data = [] + + def model_dump(self, by_alias=True): + return { + "items": self.items, + "insights": self.insights, + "rawData": self.raw_data, + } + + class FakeClient: + def __init__(self): + self.calls = [] + + def retrieve(self, *args, **kwargs): + self.calls.append(kwargs) + if len(self.calls) == 1: + return FakeResponse( + items=[ + {"id": "same", "text": "Project directive", "category": "directive", "metadata": {"projectSlug": "memind-main"}, "finalScore": 0.9} + ], + ) + return FakeResponse( + items=[ + {"id": "same", "text": "Duplicate project directive", "category": "directive", "metadata": {"projectSlug": "memind-main"}, "finalScore": 0.95}, + {"id": "g1", "text": "User prefers Chinese replies", "category": "behavior", "metadata": {}, "finalScore": 0.91}, + {"id": "s1", "text": "Run both integration tests after hook edits", "category": "tool", "metadata": {"projectSlug": "other-project"}, "finalScore": 0.8}, + {"id": "low", "text": "Weak unrelated memory", "category": "event", "metadata": {}, "finalScore": 0.2}, + ], + insights=[ + {"id": "gi1", "text": "Shared testing insight", "tier": "root", "metadata": {}} + ], + ) + + client = FakeClient() + result = build_prompt_context( + client, + {"userId": "u", "agentId": "a"}, + "fix retrieval", + "memind-main", + { + "retrieveStrategy": "SIMPLE", + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65, + }, + ) + + self.assertEqual(len(client.calls), 2) + self.assertIsNone(client.calls[1].get("metadata_filter")) + item_ids = [item["id"] for item in result["items"]] + self.assertEqual(item_ids, ["same", "g1", "s1"]) + sources = {item["id"]: item["memindContextSource"] for item in result["items"]} + self.assertEqual(sources["same"], "project") + self.assertEqual(sources["g1"], "global") + self.assertEqual(sources["s1"], "shared") + self.assertNotIn("low", item_ids) + self.assertEqual(result["insights"][0]["memindContextSource"], "global") +``` + +- [ ] **Step 2: Write equivalent Codex prompt context tests** + +Copy the same test file to `memind-integrations/codex/tests/test_prompt_context.py`, changing only `ROOT` resolution if the existing Codex tests use a different convention. The assertions should remain identical. + +- [ ] **Step 3: Run new tests and verify they fail** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_prompt_context.py \ + memind-integrations/codex/tests/test_prompt_context.py +``` + +Expected: import failure because `scripts.lib.prompt_context` does not exist. + +- [ ] **Step 4: Implement `prompt_context.py` in Claude Code** + +Create `memind-integrations/claude-code/scripts/lib/prompt_context.py`: + +```python +def project_metadata_filter(project_slug): + return {"all": [{"path": "projectSlug", "op": "eq", "value": project_slug}]} + + +def build_prompt_context(client, identity, query, project_slug, config): + project_data = _retrieve( + client, + identity, + query, + config, + metadata_filter=project_metadata_filter(project_slug) if project_slug else None, + ) + _mark_sources(project_data, project_slug, default_source="project") + + project_count = len(project_data.get("items") or []) + len(project_data.get("insights") or []) + min_entries = int(config.get("promptContextProjectMinEntries", 4)) + fallback_limit = int(config.get("promptContextGlobalFallbackEntries", 3)) + + if project_count >= min_entries or fallback_limit <= 0: + return _shape(project_data, project_slug) + + fallback_data = _retrieve(client, identity, query, config, metadata_filter=None) + _mark_sources(fallback_data, project_slug) + fallback_data = _filter_fallback(fallback_data, config, fallback_limit) + + return _shape(_merge(project_data, fallback_data), project_slug) + + +def _retrieve(client, identity, query, config, metadata_filter=None): + response = client.retrieve( + identity["userId"], + identity["agentId"], + query, + config.get("retrieveStrategy", "SIMPLE"), + False, + metadata_filter=metadata_filter, + include={"raw_data_metadata": True}, + ) + return _dump_response(response) + + +def _dump_response(response): + if hasattr(response, "model_dump"): + data = response.model_dump(by_alias=True) + else: + data = { + "items": list(getattr(response, "items", []) or []), + "insights": list(getattr(response, "insights", []) or []), + "rawData": list(getattr(response, "raw_data", []) or getattr(response, "rawData", []) or []), + } + + return { + "items": [_dump_entry(entry) for entry in data.get("items", []) or []], + "insights": [_dump_entry(entry) for entry in data.get("insights", []) or []], + "rawData": [_dump_entry(entry) for entry in data.get("rawData", []) or data.get("raw_data", []) or []], + } + + +def _dump_entry(entry): + if isinstance(entry, dict): + return dict(entry) + if hasattr(entry, "model_dump"): + return entry.model_dump(by_alias=True) + return { + key: value + for key, value in vars(entry).items() + if not key.startswith("_") + } + + +def _shape(data, project_slug): + return { + "projectSlug": project_slug, + "mode": "project-first", + "items": data.get("items") or [], + "insights": data.get("insights") or [], + "rawData": data.get("rawData") or data.get("raw_data") or [], + } + + +def _merge(project_data, fallback_data): + return { + "items": _dedupe((project_data.get("items") or []) + (fallback_data.get("items") or [])), + "insights": _dedupe((project_data.get("insights") or []) + (fallback_data.get("insights") or [])), + "rawData": _dedupe((project_data.get("rawData") or []) + (fallback_data.get("rawData") or [])), + } + + +def _filter_fallback(data, config, limit): + min_score = float(config.get("promptContextGlobalFallbackMinScore", 0.65)) + return { + "items": [ + entry + for entry in data.get("items", []) + if entry.get("memindContextSource") != "project" and _passes_fallback_score(entry, min_score) + ][:limit], + "insights": [ + entry + for entry in data.get("insights", []) + if entry.get("memindContextSource") != "project" and _passes_fallback_score(entry, min_score) + ][:limit], + "rawData": [], + } + + +def _mark_sources(data, project_slug, default_source=None): + # Mark only dumped dictionaries, not Pydantic model objects. MemindModel uses + # extra="ignore", so annotating model instances before model_dump can lose + # memindContextSource. + for key in ("items", "insights", "rawData", "raw_data"): + for entry in data.get(key) or []: + entry["memindContextSource"] = _source_for(entry, project_slug, default_source) + + +def _source_for(entry, project_slug, default_source=None): + metadata = _field(entry, "metadata") or {} + entry_project = metadata.get("projectSlug") if isinstance(metadata, dict) else None + if entry_project and project_slug and entry_project == project_slug: + return "project" + if entry_project: + return "shared" + if default_source: + return default_source + return "global" + + +def _dedupe(entries): + result = [] + seen = set() + for entry in entries: + key = _entry_key(entry) + if not key or key in seen: + continue + seen.add(key) + result.append(entry) + return result + + +def _entry_key(entry): + entry_id = _field(entry, "id") or _field(entry, "rawDataId") or _field(entry, "raw_data_id") + if entry_id: + return f"id:{entry_id}" + text = " ".join(str(_field(entry, "text") or _field(entry, "caption") or "").lower().split()) + return f"text:{text[:260]}" if text else "" + + +def _passes_fallback_score(entry, min_score): + score = _score(entry) + if score is None: + return True + return score >= min_score + + +def _score(entry): + for key in ("finalScore", "final_score", "vectorScore", "vector_score", "score"): + value = _field(entry, key) + if value is None: + continue + try: + return float(value) + except (TypeError, ValueError): + continue + return None + + +def _field(value, name): + if isinstance(value, dict): + return value.get(name) + return getattr(value, name, None) + + +``` + +- [ ] **Step 5: Copy helper to Codex** + +Copy the same implementation into `memind-integrations/codex/scripts/lib/prompt_context.py`. + +- [ ] **Step 6: Run prompt context tests** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_prompt_context.py \ + memind-integrations/codex/tests/test_prompt_context.py +``` + +Expected: pass. + +- [ ] **Step 7: Commit** + +```bash +git add \ + memind-integrations/claude-code/scripts/lib/prompt_context.py \ + memind-integrations/claude-code/tests/test_prompt_context.py \ + memind-integrations/codex/scripts/lib/prompt_context.py \ + memind-integrations/codex/tests/test_prompt_context.py +git commit -m "feat(agent): retrieve prompt context project first" +``` + +## Task 3: Render Prompt Context Provenance + +**Files:** + +- Modify `memind-integrations/claude-code/scripts/lib/context_compiler.py` +- Modify `memind-integrations/codex/scripts/lib/context_compiler.py` +- Test `memind-integrations/claude-code/tests/test_context_compiler.py` +- Test `memind-integrations/codex/tests/test_context_compiler.py` + +- [ ] **Step 1: Write failing compiler tests** + +Add to both `test_context_compiler.py` files: + +```python +def test_prompt_context_renders_project_first_attrs_and_source_labels(self): + from scripts.lib.context_compiler import compile_prompt_retrieval_context + + rendered = compile_prompt_retrieval_context( + { + "projectSlug": "memind-main", + "mode": "project-first", + "items": [ + { + "id": "dir-1", + "text": "Keep userId and agentId stable.", + "category": "directive", + "createdAt": "2026-05-27T10:00:00Z", + "finalScore": 0.9, + "memindContextSource": "project", + }, + { + "id": "beh-1", + "text": "User prefers Chinese replies.", + "category": "behavior", + "createdAt": "2026-05-20T10:00:00Z", + "finalScore": 0.88, + "memindContextSource": "global", + }, + ], + "insights": [ + { + "id": "ins-1", + "text": "Run both Claude Code and Codex tests after hook edits.", + "tier": "root", + "createdAt": "2026-05-18T10:00:00Z", + "memindContextSource": "shared", + } + ], + }, + { + "retrieveMaxEntries": 8, + "retrieveMaxChars": 6000, + "retrievePromptPreamble": "Relevant memories from Memind.", + }, + ) + + self.assertIn('', rendered) + self.assertIn("[item:dir-1 directive, project, 2026-05-27]", rendered) + self.assertIn("[item:beh-1 behavior, global, 2026-05-20]", rendered) + self.assertIn("[insight:ins-1 root, shared, 2026-05-18]", rendered) +``` + +- [ ] **Step 2: Run compiler tests and verify failure** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_context_compiler.py \ + memind-integrations/codex/tests/test_context_compiler.py +``` + +Expected: failure because wrapper attrs and source labels are not rendered yet. + +- [ ] **Step 3: Update normalizers** + +In both `context_compiler.py`, add `source` to `_normalize_retrieved_item(...)`: + +```python +"source": _field(item, "memindContextSource"), +``` + +Add `source` to `_normalize_insight(...)`: + +```python +"source": _field(insight, "memindContextSource"), +``` + +No source field is needed for session or tool contexts. + +- [ ] **Step 4: Update prompt wrapper attrs** + +In `compile_prompt_retrieval_context(...)`, replace: + +```python +attrs={}, +``` + +with: + +```python +attrs=_prompt_attrs(data), +``` + +for both normal and notice-only render paths. + +Add helper: + +```python +def _prompt_attrs(data): + attrs = {} + if data.get("projectSlug"): + attrs["project"] = data["projectSlug"] + if data.get("mode"): + attrs["mode"] = data["mode"] + return attrs +``` + +- [ ] **Step 5: Update entry label rendering** + +In `_render_entry(...)`, after date handling, include source before date: + +```python +source = entry.get("source") +label_parts = [label] +if source: + label_parts.append(source) +if date: + label_parts.append(date) +label = ", ".join(label_parts) +``` + +Replace the existing: + +```python +if date: + label = f"{label}, {date}" +``` + +with the new `label_parts` logic. + +- [ ] **Step 6: Run compiler tests** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_context_compiler.py \ + memind-integrations/codex/tests/test_context_compiler.py +``` + +Expected: pass. + +- [ ] **Step 7: Commit** + +```bash +git add \ + memind-integrations/claude-code/scripts/lib/context_compiler.py \ + memind-integrations/claude-code/tests/test_context_compiler.py \ + memind-integrations/codex/scripts/lib/context_compiler.py \ + memind-integrations/codex/tests/test_context_compiler.py +git commit -m "feat(agent): label prompt memory provenance" +``` + +## Task 4: Wire Prompt Context Into UserPromptSubmit + +**Files:** + +- Modify `memind-integrations/claude-code/scripts/retrieve.py` +- Modify `memind-integrations/codex/scripts/retrieve.py` +- Test `memind-integrations/claude-code/tests/test_hooks.py` +- Test `memind-integrations/codex/tests/test_hooks.py` + +- [ ] **Step 1: Write failing Claude Code hook tests** + +Add to `memind-integrations/claude-code/tests/test_hooks.py`: + +```python +def test_retrieve_default_does_not_call_memind_but_buffers_prompt(self): + sys.path.insert(0, str(ROOT / "scripts")) + import retrieve + + retrieve = importlib.reload(retrieve) + + config = { + "sourceClient": "claude-code", + "autoRetrieve": True, + "autoPromptContext": False, + "retrieveContextTurns": 0, + } + + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + with mock.patch.object(retrieve, "state_root", return_value=state_dir): + with mock.patch.object(retrieve, "load_config", return_value=config): + with mock.patch.object(retrieve, "MemindClient") as client_cls: + result = retrieve.handle_user_prompt_submit( + { + "hook_event_name": "UserPromptSubmit", + "cwd": tmp, + "session_id": "s1", + "prompt": "Fix payment tests", + } + ) + + self.assertEqual(result, {"continue": True}) + client_cls.assert_not_called() + state_file = next(state_dir.glob("*.json")) + event = json.loads(state_file.read_text())["agentEvents"][0] + self.assertEqual(event["kind"], "user_prompt") + self.assertEqual(event["text"], "Fix payment tests") + + +def test_retrieve_prompt_context_enabled_uses_project_first_context(self): + sys.path.insert(0, str(ROOT / "scripts")) + import retrieve + + retrieve = importlib.reload(retrieve) + + config = { + "sourceClient": "claude-code", + "memindApiUrl": "http://127.0.0.1:8366", + "memindApiToken": None, + "autoRetrieve": True, + "autoPromptContext": True, + "retrieveContextTurns": 0, + "retrieveStrategy": "SIMPLE", + "retrieveMaxEntries": 8, + "retrieveMaxChars": 6000, + "retrievePromptPreamble": "Relevant memories from Memind.", + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65, + } + + class FakeClient: + pass + + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + with mock.patch.object(retrieve, "state_root", return_value=state_dir): + with mock.patch.object(retrieve, "load_config", return_value=config): + with mock.patch.object(retrieve, "resolve_identity", return_value={"userId": "u", "agentId": "a"}): + with mock.patch.object(retrieve, "project_slug", return_value="memind-main"): + with mock.patch.object(retrieve, "MemindClient", return_value=FakeClient()): + with mock.patch.object( + retrieve, + "build_prompt_context", + return_value={ + "projectSlug": "memind-main", + "mode": "project-first", + "items": [ + { + "id": "dir-1", + "text": "Keep ids stable.", + "category": "directive", + "memindContextSource": "project", + } + ], + "insights": [], + }, + ) as build_context: + result = retrieve.handle_user_prompt_submit( + { + "hook_event_name": "UserPromptSubmit", + "cwd": tmp, + "session_id": "s1", + "prompt": "Fix payment tests", + } + ) + + build_context.assert_called_once() + args = build_context.call_args.args + self.assertEqual(args[3], "memind-main") + context = result["hookSpecificOutput"]["additionalContext"] + self.assertIn('', context) + self.assertIn("[item:dir-1 directive, project]", context) +``` + +If `retrieve.py` does not yet expose `handle_user_prompt_submit(...)`, the test should fail until Step 3 introduces it. + +- [ ] **Step 2: Write equivalent Codex hook tests** + +Add equivalent tests to `memind-integrations/codex/tests/test_hooks.py` with: + +- `sourceClient`: `"codex"` +- prompt field can be `"prompt"` or `"user_prompt"`; include one test using `"user_prompt"` to preserve Codex behavior. +- Use Codex `state_key(...)` behavior in assertions if existing tests use it. +- Include the same `build_context.call_args.args[3] == "memind-main"` assertion so the test verifies current-project attribution is passed into the helper. + +- [ ] **Step 3: Run hook tests and verify failure** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_hooks.py \ + memind-integrations/codex/tests/test_hooks.py +``` + +Expected: failure because `handle_user_prompt_submit(...)` and prompt helper wiring do not exist. + +- [ ] **Step 4: Refactor Claude Code `retrieve.py`** + +Modify imports: + +```python +from pathlib import Path +from lib.identity import project_slug, resolve_identity +from lib.prompt_context import build_prompt_context +``` + +Extract the current `main()` body into: + +```python +def handle_user_prompt_submit(hook_input): + config = load_config() + session_id = hook_input.get("session_id") or "unknown-session" + prompt = hook_input.get("prompt") or "" + hook_input["source_client"] = config.get("sourceClient") or "claude-code" + with SessionStateStore(state_root()).locked(session_id) as state: + turn_id, turn_seq = state.start_agent_turn(session_id) + seq = state.next_agent_seq() + state.append_agent_event( + normalize_user_prompt_event( + hook_input, seq, turn_id=turn_id, turn_seq=turn_seq + ) + ) + + if not config.get("autoRetrieve", True): + return {"continue": True} + if not config.get("autoPromptContext", False): + return {"continue": True} + + identity = resolve_identity(config, hook_input) + context_turns = int(config.get("retrieveContextTurns", 0)) + recent_context = read_recent_context(hook_input.get("transcript_path"), context_turns) + query = prompt if not recent_context else f"{recent_context}\ncurrent: {prompt}" + cwd = hook_input.get("cwd") or os.getcwd() + slug = project_slug(Path(cwd)) + client = MemindClient(config["memindApiUrl"], config.get("memindApiToken"), timeout=12, max_retries=0) + result = build_prompt_context(client, identity, query, slug, config) + context = _format_context(result, config) + if not context: + return {"continue": True} + return {"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": context}} +``` + +Then simplify `main()`: + +```python +def main(): + try: + hook_input = json.loads(sys.stdin.read() or "{}") + print(json.dumps(handle_user_prompt_submit(hook_input))) + except Exception as exc: + try: + debug_log(load_config(), "retrieve_failed", {"error": str(exc)}) + except Exception: + pass + print(json.dumps({"continue": True})) +``` + +- [ ] **Step 5: Refactor Codex `retrieve.py`** + +Modify imports: + +```python +from pathlib import Path +from lib.identity import project_slug, resolve_identity +from lib.prompt_context import build_prompt_context +``` + +Extract the current `main()` body into: + +```python +def handle_user_prompt_submit(hook_input): + config = load_config() + prompt = hook_input.get("prompt") or hook_input.get("user_prompt") or "" + hook_input["source_client"] = config.get("sourceClient") or "codex" + session_key = state_key(hook_input) + with SessionStateStore(state_root()).locked(session_key) as state: + turn_id, turn_seq = state.start_agent_turn(session_key) + seq = state.next_agent_seq() + state.append_agent_event( + normalize_user_prompt_event( + hook_input, seq, turn_id=turn_id, turn_seq=turn_seq + ) + ) + + if not config.get("autoRetrieve", True): + return {"continue": True} + if not config.get("autoPromptContext", False): + return {"continue": True} + + identity = resolve_identity(config, hook_input) + context_turns = int(config.get("retrieveContextTurns", 0)) + recent_context = read_recent_context(hook_input.get("transcript_path"), context_turns) + query = prompt if not recent_context else f"{recent_context}\ncurrent: {prompt}" + cwd = hook_input.get("cwd") or os.getcwd() + slug = project_slug(Path(cwd)) + client = MemindClient(config["memindApiUrl"], config.get("memindApiToken"), timeout=12, max_retries=0) + result = build_prompt_context(client, identity, query, slug, config) + context = _format_context(result, config) + if not context: + return {"continue": True} + return {"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": context}} +``` + +Then simplify `main()`: + +```python +def main(): + try: + hook_input = json.loads(sys.stdin.read() or "{}") + print(json.dumps(handle_user_prompt_submit(hook_input))) + except Exception as exc: + try: + debug_log(load_config(), "retrieve_failed", {"error": str(exc)}) + except Exception: + pass + print(json.dumps({"continue": True})) +``` + +- [ ] **Step 6: Run hook tests** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_hooks.py \ + memind-integrations/codex/tests/test_hooks.py +``` + +Expected: pass. + +- [ ] **Step 7: Commit** + +```bash +git add \ + memind-integrations/claude-code/scripts/retrieve.py \ + memind-integrations/claude-code/tests/test_hooks.py \ + memind-integrations/codex/scripts/retrieve.py \ + memind-integrations/codex/tests/test_hooks.py +git commit -m "feat(agent): gate prompt context injection" +``` + +## Task 5: Update Documentation + +**Files:** + +- Modify `memind-integrations/claude-code/README.md` +- Modify `memind-integrations/codex/README.md` + +- [ ] **Step 1: Update capability summary** + +In both READMEs, change text that currently says retrieval happens before each prompt by default. Use this wording: + +```markdown +- **Prompt context (optional)**: `UserPromptSubmit` always buffers the user prompt into the local agent timeline. If `autoPromptContext=true`, it also retrieves project-first Memind memories with a bounded global fallback and injects them as `...`. +``` + +- [ ] **Step 2: Update hook table** + +Change `UserPromptSubmit` description to: + +```markdown +| `UserPromptSubmit` | `scripts/retrieve.py` | 12s | Buffer the user prompt event. Optionally inject project-first prompt memory when `autoPromptContext=true`. | +``` + +- [ ] **Step 3: Update settings table** + +Add rows: + +```markdown +| `autoPromptContext` | `false` | Enables prompt-time `` retrieval and injection on `UserPromptSubmit`. Off by default to avoid token cost and unrelated cross-project recall. | +| `promptContextProjectMinEntries` | `4` | Minimum current-project entries before global fallback is skipped. | +| `promptContextGlobalFallbackEntries` | `3` | Maximum fallback entries from the shared memory space when current-project results are sparse. | +| `promptContextGlobalFallbackMinScore` | `0.65` | Minimum score for fallback entries. | +``` + +Update `autoRetrieve` row: + +```markdown +| `autoRetrieve` | `true` | Backward-compatible broad retrieval gate used by prompt and tool retrieval paths. Leave enabled unless you want to disable retrieval-assisted contexts entirely. | +``` + +- [ ] **Step 4: Update examples** + +Add an example: + +```json +{ + "autoPromptContext": true, + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65 +} +``` + +Add example rendered context: + +```xml + +Relevant memories from Memind. Use only when directly helpful: + +## Directives +- [item:dir-1 directive, project, 2026-05-27] Keep userId and agentId stable across Claude Code, Codex, and API clients. +- [item:beh-1 behavior, global, 2026-05-20] User prefers Chinese replies for technical discussions. + +## Tool Notes +- [item:tool-9 tool, shared, 2026-05-18] After hook integration changes, run both Claude Code and Codex integration tests. + +``` + +- [ ] **Step 5: Run docs grep sanity** + +Run: + +```bash +rg -n "autoPromptContext|promptContextProjectMinEntries|Prompt context \\(optional\\)|project-first" \ + memind-integrations/claude-code/README.md \ + memind-integrations/codex/README.md +``` + +Expected: both READMEs mention the new settings and opt-in behavior. + +- [ ] **Step 6: Commit** + +```bash +git add \ + memind-integrations/claude-code/README.md \ + memind-integrations/codex/README.md +git commit -m "docs(agent): document opt-in prompt context" +``` + +## Task 6: Full Verification + +**Files:** + +- No new source files beyond previous tasks. + +- [ ] **Step 1: Run Claude Code integration tests** + +Run: + +```bash +python3 -m unittest discover memind-integrations/claude-code/tests +``` + +Expected: all Claude Code tests pass. + +- [ ] **Step 2: Run Codex integration tests** + +Run: + +```bash +python3 -m unittest discover memind-integrations/codex/tests +``` + +Expected: all Codex tests pass. + +- [ ] **Step 3: Run targeted prompt context tests** + +Run: + +```bash +python3 -m unittest \ + memind-integrations/claude-code/tests/test_prompt_context.py \ + memind-integrations/claude-code/tests/test_hooks.py \ + memind-integrations/claude-code/tests/test_context_compiler.py \ + memind-integrations/codex/tests/test_prompt_context.py \ + memind-integrations/codex/tests/test_hooks.py \ + memind-integrations/codex/tests/test_context_compiler.py +``` + +Expected: all pass. + +- [ ] **Step 4: Check whitespace** + +Run: + +```bash +git diff --check +``` + +Expected: no output. + +- [ ] **Step 5: Confirm no generated caches are staged** + +Run: + +```bash +git status --short +``` + +Expected: + +- Source/test/doc changes are staged or committed. +- `__pycache__` directories are not staged. + +- [ ] **Step 6: Push branch** + +Run: + +```bash +git push +``` + +Expected: remote branch updates successfully. + +## Risks And Mitigations + +Risk: `autoRetrieve` naming remains confusing. + +Mitigation: Keep it only for compatibility in this plan, document it as a broad legacy gate, and use `autoPromptContext`, `autoSessionContext`, and `autoToolContext` for precise user-facing behavior. + +Risk: Global fallback may surface unrelated project memories. + +Mitigation: Fallback is skipped when project results are sufficient, capped to three entries, score-filtered, deduped, and rendered with `global`/`shared` provenance labels. + +Risk: Some retrieve responses may lack score fields. + +Mitigation: Apply score thresholds only when a score field is present. Missing-score fallback entries keep Memind's returned order and are still capped by `promptContextGlobalFallbackEntries`. This preserves `RetrievedInsight` fallback results because the current Python client insight model does not expose scores. + +Risk: Code duplication between Claude Code and Codex. + +Mitigation: Keep files intentionally mirrored because the integrations are currently packaged separately. Tests must be added to both sides so future divergence is visible. + +Risk: Prompt context disabled by default may surprise users who read older docs. + +Mitigation: Update README language and settings tables to make SessionStart and PreToolUse the default automatic context paths, while prompt context is an explicit opt-in for users who want query-aware recall on every prompt. + +## Expected User-Facing Result + +Default behavior: + +- New session receives `` when available. +- Tool calls receive `` when exact file/tool context exists. +- Each user prompt is buffered into rawdata-agent timeline. +- No `` is injected on every user prompt unless enabled. + +Opt-in behavior: + +- `autoPromptContext=true` enables query-aware prompt recall. +- The first retrieve is current-project constrained. +- If current-project memory is sparse, Memind adds high-score reusable memories from the shared `userId + agentId` memory space. +- Injected memories carry provenance labels so the coding agent can prioritize project memory and treat shared/global memory as reusable guidance, not current-repo fact. + +## Self-Review + +Spec coverage: + +- Default prompt injection disabled: Task 1 and Task 4. +- UserPromptSubmit still buffers prompt: Task 4 tests and implementation. +- Project-aware exact filtering: Task 2 helper. +- Project-first plus global fallback: Task 2 helper and tests. +- Not project-only: Task 2 fallback uses no project metadata filter. +- Source labels: Task 3. +- Claude Code and Codex parity: every task touches both integrations. +- No core/OpenAPI changes: file map and non-goals constrain the work to integration code. + +Placeholder scan: + +- No TODO/TBD placeholders. +- Every implementation task names concrete files, commands, and expected outcomes. + +Type consistency: + +- New setting name is consistently `autoPromptContext`. +- New helper name is consistently `build_prompt_context`. +- New provenance field is consistently `memindContextSource`. +- Existing wrapper remains `memind_memories`. diff --git a/memind-integrations/claude-code/README.md b/memind-integrations/claude-code/README.md index 0a223d8a..5a8b4795 100644 --- a/memind-integrations/claude-code/README.md +++ b/memind-integrations/claude-code/README.md @@ -1,8 +1,8 @@ # Memind Claude Code Integration -Memind adds persistent project memory to Claude Code. The plugin retrieves relevant Memind context before each -user prompt and submits Claude Code coding-agent timelines through Memind's reliable extraction endpoint during -session lifecycle hooks. +Memind adds persistent project memory to Claude Code. The plugin injects project continuity context at session +start, can inject exact file/tool context before high-value tools, and submits Claude Code coding-agent timelines +through Memind's reliable extraction endpoint during session lifecycle hooks. Use this plugin when you want Claude Code to remember project facts, preferences, implementation decisions, and previous discussions across sessions. The plugin connects Claude Code to an already-running Memind server; it @@ -18,8 +18,9 @@ The integration is intentionally small: ## What It Does -- **Retrieval**: `UserPromptSubmit` calls `MemindClient.memory.retrieve(...)` and injects relevant memories into - Claude Code as `...` additional context. +- **Prompt context (optional)**: `UserPromptSubmit` always buffers the user prompt into the local agent timeline. + If `autoPromptContext=true`, it also retrieves project-first Memind memories with a bounded global fallback and + injects them as `...`. - **Ingestion**: `PreToolUse` and `PostToolUse` buffer normalized tool events locally. `Stop` flushes the complete turn as `rawContent.type = "agent_timeline"` through `AsyncMemindClient.memory.extract(...)`, so Memind can extract user and agent memories from the same agent turn. `PreCompact` only records a local `compact_boundary` checkpoint; @@ -103,7 +104,7 @@ The installed hooks are: | Claude Code event | Script | Timeout | Purpose | | --- | --- | ---: | --- | | `SessionStart` | `scripts/session_start.py` | 5s | Health check, replay at most one failed retry payload, clean old state, and inject project continuity context when available. | -| `UserPromptSubmit` | `scripts/retrieve.py` | 12s | Buffer the user prompt event and retrieve relevant Memind context. | +| `UserPromptSubmit` | `scripts/retrieve.py` | 12s | Buffer the user prompt event. Optionally inject project-first prompt memory when `autoPromptContext=true`. | | `PreToolUse` | `scripts/pre_tool_use.py` | 5s | Buffer a redacted tool-start event and, for high-value file edits or commands, inject compact file/tool memory context. | | `PostToolUse` | `scripts/post_tool_use.py` | 5s | Buffer a redacted tool-result event in local session state. | | `Notification` | `scripts/notification.py` | 5s | Buffer permission, blocking, and other user-visible lifecycle notifications. | @@ -130,6 +131,7 @@ User configuration is optional. Save overrides as `~/.memind/claude-code.json`: "agentId": "coding-agent", "sourceClient": "claude-code", "autoIngestAgentTimeline": true, + "autoPromptContext": false, "retrieveContextTurns": 0 } ``` @@ -150,7 +152,8 @@ Settings are loaded in this order: | `userId` | `local__` | Memind user identity. | | `agentId` | `coding-agent` | Shared Memind agent identity. Use the same value from Claude Code, Codex, and API clients to share one coding-agent memory space. | | `sourceClient` | `claude-code` | Source marker stored with Memind data. | -| `autoRetrieve` | `true` | Enables prompt-time memory retrieval. | +| `autoRetrieve` | `true` | Backward-compatible broad retrieval gate used by prompt and tool retrieval paths. Leave enabled unless you want to disable retrieval-assisted contexts entirely. | +| `autoPromptContext` | `false` | Enables prompt-time `` retrieval and injection on `UserPromptSubmit`. Off by default to avoid token cost and unrelated cross-project recall. | | `autoSessionContext` | `true` | Enables SessionStart project continuity context injection. | | `autoIngestAgentTimeline` | `true` | Enables user prompt, tool/result, assistant message, and stop event buffering plus `agent_timeline` rawdata flush. | | `autoToolContext` | `true` | Enables compact PreToolUse context for high-value file edits and commands. | @@ -158,6 +161,9 @@ Settings are loaded in this order: | `retrieveMaxEntries` | `8` | Maximum formatted memory entries injected into Claude Code. | | `retrieveMaxChars` | `6000` | Maximum injected context characters. | | `retrieveContextTurns` | `0` | Number of recent transcript turns to include in the retrieval query. | +| `promptContextProjectMinEntries` | `4` | Minimum current-project entries before global fallback is skipped. | +| `promptContextGlobalFallbackEntries` | `3` | Maximum fallback entries from the shared memory space when current-project results are sparse. | +| `promptContextGlobalFallbackMinScore` | `0.65` | Minimum score for fallback entries. | | `toolContextMaxChars` | `3500` | Maximum injected PreToolUse context characters. | | `toolContextEntryMaxChars` | `520` | Maximum characters per PreToolUse context entry. | | `toolContextMaxItems` | `6` | Maximum exact or fallback items considered for PreToolUse context. | @@ -178,6 +184,7 @@ export MEMIND_API_TOKEN=... export MEMIND_USER_ID=local__alice export MEMIND_AGENT_ID=coding-agent export MEMIND_SOURCE_CLIENT=claude-code +export MEMIND_AUTO_PROMPT_CONTEXT=false export MEMIND_AUTO_SESSION_CONTEXT=true export MEMIND_AUTO_TOOL_CONTEXT=true export MEMIND_AUTO_INGEST_AGENT_TIMELINE=true @@ -190,6 +197,8 @@ export MEMIND_DEBUG=true Additional environment variables include `MEMIND_AUTO_RETRIEVE`, `MEMIND_RETRIEVE_STRATEGY`, `MEMIND_RETRIEVE_MAX_ENTRIES`, `MEMIND_RETRIEVE_MAX_CHARS`, `MEMIND_STATE_MAX_AGE_DAYS`, `MEMIND_SESSION_CONTEXT_RECENT_SESSIONS`, `MEMIND_SESSION_CONTEXT_MAX_ITEMS`, +`MEMIND_PROMPT_CONTEXT_PROJECT_MIN_ENTRIES`, `MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_ENTRIES`, +`MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_MIN_SCORE`, `MEMIND_TOOL_CONTEXT_ENTRY_MAX_CHARS`, `MEMIND_TOOL_CONTEXT_MAX_ITEMS`, `MEMIND_TOOL_CONTEXT_MIN_EXACT_ITEMS`, `MEMIND_INGEST_RETRY_SPOOL`, `MEMIND_INGEST_RETRY_MAX_FILES`, and `MEMIND_INGEST_RETRY_MAX_AGE_DAYS`. @@ -240,33 +249,50 @@ Historical Memind project memory. Use only when directly helpful. Current user i This project-continuity context is separate from prompt-time retrieval. It helps a new Claude Code session know what recently happened in this project before the first user prompt is handled. -## Retrieval Behavior +## Prompt Context -Retrieval runs before each user prompt when `autoRetrieve = true`. +Prompt context is disabled by default. `UserPromptSubmit` still buffers the user prompt into the local +`agent_timeline` state, but it does not inject `` unless `autoPromptContext = true`. + +Enable prompt-time recall only when you want query-aware memory on every prompt: + +```json +{ + "autoPromptContext": true, + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65 +} +``` + +When enabled, Memind first retrieves memories constrained by the current project's `metadata.projectSlug`. If those +project hits are sparse, it adds a bounded global fallback from the same `userId + agentId` memory space. The injected +context marks source provenance as `project`, `global`, or `shared`. The injected context format is: ```text - + Relevant memories from Memind. Use only when directly helpful: ## Directives -- [item:201 directive] Do not default Claude Code or Codex to conversation rawdata. +- [item:201 directive, project, 2026-05-27] Do not default Claude Code or Codex to conversation rawdata. +- [item:206 behavior, global, 2026-05-20] User prefers Chinese replies for technical discussions. ## Resolved Problems -- [item:202 resolution] Retry spool events are cleared only after successful agent_timeline extraction. +- [item:202 resolution, project, 2026-05-27] Retry spool events are cleared only after successful agent_timeline extraction. ## Agent Playbooks -- [item:203 playbook] When hooks change, run both integration test suites and git diff --check. +- [item:203 playbook, project, 2026-05-27] When hooks change, run both integration test suites and git diff --check. ## Tool Notes -- [item:204 tool] Use Python 3.12 for the Codex integration test suite. +- [item:204 tool, shared, 2026-05-18] Use Python 3.12 for the Codex integration test suite. ## Insights -- [insight:301 root] Coding-agent integrations share memory through stable userId and agentId. +- [insight:301 root, project, 2026-05-27] Coding-agent integrations share memory through stable userId and agentId. ## Memory Items -- [item:205 event] rawdata-agent emits agent_episode segment metadata. +- [item:205 event, project, 2026-05-27] rawdata-agent emits agent_episode segment metadata. ``` @@ -542,6 +568,7 @@ curl -fsSL http://127.0.0.1:8366/open/v1/health ``` - Confirm `autoRetrieve` is `true`. +- Confirm `autoPromptContext` is `true` for prompt-time `` injection. SessionStart and PreToolUse context use separate switches. - Confirm existing memories are stored under the same `userId` and `agentId`. - Try setting `retrieveContextTurns` to `1` or `2` if the current prompt is very short. diff --git a/memind-integrations/claude-code/scripts/lib/config.py b/memind-integrations/claude-code/scripts/lib/config.py index bf0b3d1e..776cc340 100644 --- a/memind-integrations/claude-code/scripts/lib/config.py +++ b/memind-integrations/claude-code/scripts/lib/config.py @@ -23,6 +23,7 @@ "agentId": "coding-agent", "sourceClient": "claude-code", "autoRetrieve": True, + "autoPromptContext": False, "autoSessionContext": True, "autoIngestAgentTimeline": True, "retrieveStrategy": "SIMPLE", @@ -30,6 +31,9 @@ "retrieveMaxChars": 6000, "retrievePromptPreamble": "Relevant memories from Memind. Use only when directly helpful:", "retrieveContextTurns": 0, + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65, "autoToolContext": True, "toolContextMaxChars": 3500, "toolContextEntryMaxChars": 520, @@ -52,12 +56,16 @@ "MEMIND_AGENT_ID": ("agentId", str), "MEMIND_SOURCE_CLIENT": ("sourceClient", str), "MEMIND_AUTO_RETRIEVE": ("autoRetrieve", "bool"), + "MEMIND_AUTO_PROMPT_CONTEXT": ("autoPromptContext", "bool"), "MEMIND_AUTO_SESSION_CONTEXT": ("autoSessionContext", "bool"), "MEMIND_AUTO_INGEST_AGENT_TIMELINE": ("autoIngestAgentTimeline", "bool"), "MEMIND_RETRIEVE_STRATEGY": ("retrieveStrategy", str), "MEMIND_RETRIEVE_MAX_ENTRIES": ("retrieveMaxEntries", "int"), "MEMIND_RETRIEVE_MAX_CHARS": ("retrieveMaxChars", "int"), "MEMIND_RETRIEVE_CONTEXT_TURNS": ("retrieveContextTurns", "int_allow_zero"), + "MEMIND_PROMPT_CONTEXT_PROJECT_MIN_ENTRIES": ("promptContextProjectMinEntries", "int_allow_zero"), + "MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_ENTRIES": ("promptContextGlobalFallbackEntries", "int_allow_zero"), + "MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_MIN_SCORE": ("promptContextGlobalFallbackMinScore", "float_allow_zero"), "MEMIND_AUTO_TOOL_CONTEXT": ("autoToolContext", "bool"), "MEMIND_TOOL_CONTEXT_MAX_CHARS": ("toolContextMaxChars", "int"), "MEMIND_TOOL_CONTEXT_ENTRY_MAX_CHARS": ("toolContextEntryMaxChars", "int"), @@ -85,6 +93,13 @@ def parse_int(value, name, allow_zero=False): return parsed +def parse_float(value, name, allow_zero=False): + parsed = float(value) + if parsed < 0 or (parsed == 0 and not allow_zero): + raise ValueError(f"{name} must be positive") + return parsed + + def parse_list(value): return [part.strip() for part in str(value).split(",") if part.strip()] @@ -96,6 +111,8 @@ def _coerce(value, kind, name): return parse_int(value, name) if kind == "int_allow_zero": return parse_int(value, name, allow_zero=True) + if kind == "float_allow_zero": + return parse_float(value, name, allow_zero=True) if kind == "list": return parse_list(value) return kind(value) diff --git a/memind-integrations/claude-code/scripts/lib/context_compiler.py b/memind-integrations/claude-code/scripts/lib/context_compiler.py index d4665025..6785c24c 100644 --- a/memind-integrations/claude-code/scripts/lib/context_compiler.py +++ b/memind-integrations/claude-code/scripts/lib/context_compiler.py @@ -165,7 +165,7 @@ def compile_prompt_retrieval_context(data, config): ) rendered = _render_context( wrapper="memind_memories", - attrs={}, + attrs=_prompt_attrs(data), preamble=preamble, sections=_prepare_sections(sections, "prompt_retrieval"), order=PROMPT_SECTION_ORDER, @@ -178,7 +178,7 @@ def compile_prompt_retrieval_context(data, config): return rendered return _render_context( wrapper="memind_memories", - attrs={}, + attrs=_prompt_attrs(data), preamble=preamble, sections={}, order=PROMPT_SECTION_ORDER, @@ -190,6 +190,15 @@ def compile_prompt_retrieval_context(data, config): ) +def _prompt_attrs(data): + attrs = {} + if data.get("projectSlug"): + attrs["project"] = data["projectSlug"] + if data.get("mode"): + attrs["mode"] = data["mode"] + return attrs + + def compile_tool_context(context, config): target = context.get("target") or {} items = [_normalize_tool_item(item) for item in context.get("items") or [] if _field(item, "text")] @@ -296,6 +305,7 @@ def _normalize_retrieved_item(item): "text": _clean(_field(item, "text")), "createdAt": _field(item, "createdAt") or _field(item, "created_at"), "score": _number(_field(item, "finalScore"), _field(item, "vectorScore"), 0), + "source": _field(item, "memindContextSource"), } @@ -307,6 +317,7 @@ def _normalize_insight(insight): "text": _clean(_field(insight, "text")), "createdAt": _field(insight, "createdAt") or _field(insight, "created_at"), "score": 0, + "source": _field(insight, "memindContextSource"), } @@ -499,8 +510,13 @@ def _render_entry(entry, max_chars): label = f"insight:{entry.get('id')} {entry.get('category') or 'insight'}" else: label = f"item:{entry.get('id')} {entry.get('category') or 'memory'}" + source = entry.get("source") + label_parts = [label] + if source: + label_parts.append(source) if date: - label = f"{label}, {date}" + label_parts.append(date) + label = ", ".join(label_parts) return f"- [{label}] {_clip(entry.get('text'), max_chars)}" diff --git a/memind-integrations/claude-code/scripts/lib/prompt_context.py b/memind-integrations/claude-code/scripts/lib/prompt_context.py new file mode 100644 index 00000000..875cbba3 --- /dev/null +++ b/memind-integrations/claude-code/scripts/lib/prompt_context.py @@ -0,0 +1,214 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +def project_metadata_filter(project_slug): + return {"all": [{"path": "projectSlug", "op": "eq", "value": project_slug}]} + + +def build_prompt_context(client, identity, query, project_slug, config): + project_data = _retrieve( + client, + identity, + query, + config, + metadata_filter=project_metadata_filter(project_slug) if project_slug else None, + ) + _mark_sources(project_data, project_slug, default_source="project") + + project_count = _usable_entry_count(project_data, ("items", "insights")) + min_entries = int(config.get("promptContextProjectMinEntries", 4)) + fallback_limit = int(config.get("promptContextGlobalFallbackEntries", 3)) + + if project_count >= min_entries or fallback_limit <= 0: + return _shape(project_data, project_slug) + + fallback_data = _retrieve(client, identity, query, config, metadata_filter=None) + _mark_sources(fallback_data, project_slug) + project_keys = _entry_keys(project_data) + fallback_data = _filter_fallback(fallback_data, config, fallback_limit, project_keys) + + return _shape(_merge(project_data, fallback_data), project_slug) + + +def _retrieve(client, identity, query, config, metadata_filter=None): + response = client.retrieve( + identity["userId"], + identity["agentId"], + query, + config.get("retrieveStrategy", "SIMPLE"), + False, + metadata_filter=metadata_filter, + include={"raw_data_metadata": True}, + ) + return _dump_response(response) + + +def _dump_response(response): + if isinstance(response, dict): + data = response + elif hasattr(response, "model_dump"): + data = response.model_dump(by_alias=True) + else: + data = { + "items": list(getattr(response, "items", []) or []), + "insights": list(getattr(response, "insights", []) or []), + "rawData": list(getattr(response, "raw_data", []) or getattr(response, "rawData", []) or []), + } + + return { + "items": [_dump_entry(entry) for entry in data.get("items", []) or []], + "insights": [_dump_entry(entry) for entry in data.get("insights", []) or []], + "rawData": [_dump_entry(entry) for entry in data.get("rawData", []) or data.get("raw_data", []) or []], + } + + +def _dump_entry(entry): + if isinstance(entry, dict): + return dict(entry) + if hasattr(entry, "model_dump"): + return entry.model_dump(by_alias=True) + return { + key: value + for key, value in vars(entry).items() + if not key.startswith("_") + } + + +def _shape(data, project_slug): + return { + "projectSlug": project_slug, + "mode": "project-first", + "items": data.get("items") or [], + "insights": data.get("insights") or [], + "rawData": data.get("rawData") or data.get("raw_data") or [], + } + + +def _merge(project_data, fallback_data): + return { + "items": _dedupe((project_data.get("items") or []) + (fallback_data.get("items") or [])), + "insights": _dedupe((project_data.get("insights") or []) + (fallback_data.get("insights") or [])), + "rawData": _dedupe((project_data.get("rawData") or []) + (fallback_data.get("rawData") or [])), + } + + +def _filter_fallback(data, config, limit, excluded_keys=None): + min_score = float(config.get("promptContextGlobalFallbackMinScore", 0.65)) + excluded_keys = excluded_keys or set() + return { + "items": _take_fallback_entries(data.get("items", []), min_score, limit, excluded_keys), + "insights": _take_fallback_entries(data.get("insights", []), min_score, limit, excluded_keys), + "rawData": [], + } + + +def _take_fallback_entries(entries, min_score, limit, excluded_keys): + kept = [] + seen = set() + for entry in entries: + key = _entry_key(entry) + if not key or key in excluded_keys or key in seen: + continue + if entry.get("memindContextSource") == "project": + continue + if not _passes_fallback_score(entry, min_score): + continue + kept.append(entry) + seen.add(key) + if len(kept) >= limit: + break + return kept + + +def _usable_entry_count(data, buckets): + count = 0 + for bucket in buckets: + for entry in data.get(bucket) or []: + if _field(entry, "text"): + count += 1 + return count + + +def _entry_keys(data): + keys = set() + for bucket in ("items", "insights", "rawData", "raw_data"): + for entry in data.get(bucket) or []: + key = _entry_key(entry) + if key: + keys.add(key) + return keys + + +def _mark_sources(data, project_slug, default_source=None): + for key in ("items", "insights", "rawData", "raw_data"): + for entry in data.get(key) or []: + entry["memindContextSource"] = _source_for(entry, project_slug, default_source) + + +def _source_for(entry, project_slug, default_source=None): + metadata = _field(entry, "metadata") or {} + entry_project = metadata.get("projectSlug") if isinstance(metadata, dict) else None + if entry_project and project_slug and entry_project == project_slug: + return "project" + if entry_project: + return "shared" + if default_source: + return default_source + return "global" + + +def _dedupe(entries): + result = [] + seen = set() + for entry in entries: + key = _entry_key(entry) + if not key or key in seen: + continue + seen.add(key) + result.append(entry) + return result + + +def _entry_key(entry): + entry_id = _field(entry, "id") or _field(entry, "rawDataId") or _field(entry, "raw_data_id") + if entry_id: + return f"id:{entry_id}" + text = " ".join(str(_field(entry, "text") or _field(entry, "caption") or "").lower().split()) + return f"text:{text[:260]}" if text else "" + + +def _passes_fallback_score(entry, min_score): + score = _score(entry) + if score is None: + return True + return score >= min_score + + +def _score(entry): + for key in ("finalScore", "final_score", "vectorScore", "vector_score", "score"): + value = _field(entry, key) + if value is None: + continue + try: + return float(value) + except (TypeError, ValueError): + continue + return None + + +def _field(value, name): + if isinstance(value, dict): + return value.get(name) + return getattr(value, name, None) diff --git a/memind-integrations/claude-code/scripts/retrieve.py b/memind-integrations/claude-code/scripts/retrieve.py index bde202de..d0911eb6 100644 --- a/memind-integrations/claude-code/scripts/retrieve.py +++ b/memind-integrations/claude-code/scripts/retrieve.py @@ -16,6 +16,7 @@ import json import os import sys +from pathlib import Path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) @@ -24,8 +25,9 @@ from lib.config import load_config from lib.context_compiler import compile_prompt_retrieval_context from lib.content import read_recent_context -from lib.identity import resolve_identity +from lib.identity import project_slug, resolve_identity from lib.logging_utils import debug_log +from lib.prompt_context import build_prompt_context from lib.state import SessionStateStore from ingest import state_root @@ -34,35 +36,43 @@ def _format_context(data, config): return compile_prompt_retrieval_context(data, config) +def handle_user_prompt_submit(hook_input): + config = load_config() + session_id = hook_input.get("session_id") or "unknown-session" + prompt = hook_input.get("prompt") or "" + hook_input["source_client"] = config.get("sourceClient") or "claude-code" + with SessionStateStore(state_root()).locked(session_id) as state: + turn_id, turn_seq = state.start_agent_turn(session_id) + seq = state.next_agent_seq() + state.append_agent_event( + normalize_user_prompt_event( + hook_input, seq, turn_id=turn_id, turn_seq=turn_seq + ) + ) + + if not config.get("autoRetrieve", True): + return {"continue": True} + if not config.get("autoPromptContext", False): + return {"continue": True} + + identity = resolve_identity(config, hook_input) + context_turns = int(config.get("retrieveContextTurns", 0)) + recent_context = read_recent_context(hook_input.get("transcript_path"), context_turns) + query = prompt if not recent_context else f"{recent_context}\ncurrent: {prompt}" + cwd = hook_input.get("cwd") or os.getcwd() + slug = project_slug(Path(cwd)) + client = MemindClient(config["memindApiUrl"], config.get("memindApiToken"), timeout=12, max_retries=0) + result = build_prompt_context(client, identity, query, slug, config) + context = _format_context(result, config) + if not context: + return {"continue": True} + return {"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": context}} + + def main(): try: hook_input = json.loads(sys.stdin.read() or "{}") - config = load_config() - session_id = hook_input.get("session_id") or "unknown-session" - prompt = hook_input.get("prompt") or "" - hook_input["source_client"] = config.get("sourceClient") or "claude-code" - with SessionStateStore(state_root()).locked(session_id) as state: - turn_id, turn_seq = state.start_agent_turn(session_id) - seq = state.next_agent_seq() - state.append_agent_event( - normalize_user_prompt_event( - hook_input, seq, turn_id=turn_id, turn_seq=turn_seq - ) - ) - if not config.get("autoRetrieve", True): - print(json.dumps({"continue": True})) - return - identity = resolve_identity(config, hook_input) - context_turns = int(config.get("retrieveContextTurns", 0)) - recent_context = read_recent_context(hook_input.get("transcript_path"), context_turns) - query = prompt if not recent_context else f"{recent_context}\ncurrent: {prompt}" - client = MemindClient(config["memindApiUrl"], config.get("memindApiToken"), timeout=12, max_retries=0) - result = client.retrieve(identity["userId"], identity["agentId"], query, config.get("retrieveStrategy", "SIMPLE"), False) - context = _format_context(result.model_dump(by_alias=True), config) - if not context: - print(json.dumps({"continue": True})) - return - print(json.dumps({"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": context}})) + print(json.dumps(handle_user_prompt_submit(hook_input))) except Exception as exc: try: debug_log(load_config(), "retrieve_failed", {"error": str(exc)}) diff --git a/memind-integrations/claude-code/settings.json b/memind-integrations/claude-code/settings.json index 79eac389..9ead56d2 100644 --- a/memind-integrations/claude-code/settings.json +++ b/memind-integrations/claude-code/settings.json @@ -5,6 +5,7 @@ "agentId": "coding-agent", "sourceClient": "claude-code", "autoRetrieve": true, + "autoPromptContext": false, "autoSessionContext": true, "autoIngestAgentTimeline": true, "retrieveStrategy": "SIMPLE", @@ -12,6 +13,9 @@ "retrieveMaxChars": 6000, "retrievePromptPreamble": "Relevant memories from Memind. Use only when directly helpful:", "retrieveContextTurns": 0, + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65, "autoToolContext": true, "toolContextMaxChars": 3500, "toolContextEntryMaxChars": 520, diff --git a/memind-integrations/claude-code/tests/test_config.py b/memind-integrations/claude-code/tests/test_config.py index dfa0125c..61107e44 100644 --- a/memind-integrations/claude-code/tests/test_config.py +++ b/memind-integrations/claude-code/tests/test_config.py @@ -46,6 +46,10 @@ def test_defaults_match_spec(self): self.assertEqual(DEFAULT_SETTINGS["agentId"], "coding-agent") self.assertEqual(DEFAULT_SETTINGS["retrieveContextTurns"], 0) self.assertEqual(DEFAULT_SETTINGS["sourceClient"], "claude-code") + self.assertFalse(DEFAULT_SETTINGS["autoPromptContext"]) + self.assertEqual(DEFAULT_SETTINGS["promptContextProjectMinEntries"], 4) + self.assertEqual(DEFAULT_SETTINGS["promptContextGlobalFallbackEntries"], 3) + self.assertEqual(DEFAULT_SETTINGS["promptContextGlobalFallbackMinScore"], 0.65) self.assertTrue(DEFAULT_SETTINGS["autoSessionContext"]) self.assertEqual(DEFAULT_SETTINGS["sessionContextRecentSessions"], 3) self.assertEqual(DEFAULT_SETTINGS["sessionContextMaxItems"], 6) @@ -113,6 +117,24 @@ def test_tool_context_env_overrides(self): self.assertEqual(config["toolContextMaxItems"], 4) self.assertEqual(config["toolContextMinExactItems"], 1) + def test_prompt_context_env_overrides(self): + config = load_config( + plugin_root=ROOT, + user_config_path=Path("/no/such/file"), + env={ + "CLAUDE_PLUGIN_ROOT": str(ROOT), + "MEMIND_AUTO_PROMPT_CONTEXT": "true", + "MEMIND_PROMPT_CONTEXT_PROJECT_MIN_ENTRIES": "2", + "MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_ENTRIES": "1", + "MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_MIN_SCORE": "0.5", + }, + ) + + self.assertTrue(config["autoPromptContext"]) + self.assertEqual(config["promptContextProjectMinEntries"], 2) + self.assertEqual(config["promptContextGlobalFallbackEntries"], 1) + self.assertEqual(config["promptContextGlobalFallbackMinScore"], 0.5) + if __name__ == "__main__": unittest.main() diff --git a/memind-integrations/claude-code/tests/test_context_compiler.py b/memind-integrations/claude-code/tests/test_context_compiler.py index 574d9683..181ca4d4 100644 --- a/memind-integrations/claude-code/tests/test_context_compiler.py +++ b/memind-integrations/claude-code/tests/test_context_compiler.py @@ -217,6 +217,53 @@ def test_prompt_retrieval_context_keeps_degraded_notice(self): self.assertIn("Memory retrieval encountered an error", rendered) self.assertIn("", rendered) + def test_prompt_context_renders_project_first_attrs_and_source_labels(self): + from scripts.lib.context_compiler import compile_prompt_retrieval_context + + rendered = compile_prompt_retrieval_context( + { + "projectSlug": "memind-main", + "mode": "project-first", + "items": [ + { + "id": "dir-1", + "text": "Keep userId and agentId stable.", + "category": "directive", + "createdAt": "2026-05-27T10:00:00Z", + "finalScore": 0.9, + "memindContextSource": "project", + }, + { + "id": "beh-1", + "text": "User prefers Chinese replies.", + "category": "behavior", + "createdAt": "2026-05-20T10:00:00Z", + "finalScore": 0.88, + "memindContextSource": "global", + }, + ], + "insights": [ + { + "id": "ins-1", + "text": "Run both Claude Code and Codex tests after hook edits.", + "tier": "root", + "createdAt": "2026-05-18T10:00:00Z", + "memindContextSource": "shared", + } + ], + }, + { + "retrieveMaxEntries": 8, + "retrieveMaxChars": 6000, + "retrievePromptPreamble": "Relevant memories from Memind.", + }, + ) + + self.assertIn('', rendered) + self.assertIn("[item:dir-1 directive, project, 2026-05-27]", rendered) + self.assertIn("[item:beh-1 behavior, global, 2026-05-20]", rendered) + self.assertIn("[insight:ins-1 root, shared, 2026-05-18]", rendered) + def test_tool_context_compiler_renders_bounded_file_context(self): from scripts.lib.context_compiler import compile_tool_context diff --git a/memind-integrations/claude-code/tests/test_hooks.py b/memind-integrations/claude-code/tests/test_hooks.py index 8f5cfe9b..566bb8fc 100644 --- a/memind-integrations/claude-code/tests/test_hooks.py +++ b/memind-integrations/claude-code/tests/test_hooks.py @@ -120,6 +120,105 @@ def test_format_context_groups_agent_memory_categories(self): self.assertLess(context.index("## Resolved Problems"), context.index("## Agent Playbooks")) self.assertNotIn("## Memory Items", context) + def test_retrieve_default_does_not_call_memind_but_buffers_prompt(self): + sys.path.insert(0, str(ROOT / "scripts")) + import retrieve + + retrieve = importlib.reload(retrieve) + + config = { + "sourceClient": "claude-code", + "autoRetrieve": True, + "autoPromptContext": False, + "retrieveContextTurns": 0, + } + + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + with mock.patch.object(retrieve, "state_root", return_value=state_dir): + with mock.patch.object(retrieve, "load_config", return_value=config): + with mock.patch.object(retrieve, "MemindClient") as client_cls: + result = retrieve.handle_user_prompt_submit( + { + "hook_event_name": "UserPromptSubmit", + "cwd": tmp, + "session_id": "s1", + "prompt": "Fix payment tests", + } + ) + + self.assertEqual(result, {"continue": True}) + client_cls.assert_not_called() + state_file = next(state_dir.glob("*.json")) + event = json.loads(state_file.read_text())["agentEvents"][0] + self.assertEqual(event["kind"], "user_prompt") + self.assertEqual(event["text"], "Fix payment tests") + + def test_retrieve_prompt_context_enabled_uses_project_first_context(self): + sys.path.insert(0, str(ROOT / "scripts")) + import retrieve + + retrieve = importlib.reload(retrieve) + + config = { + "sourceClient": "claude-code", + "memindApiUrl": "http://127.0.0.1:8366", + "memindApiToken": None, + "autoRetrieve": True, + "autoPromptContext": True, + "retrieveContextTurns": 0, + "retrieveStrategy": "SIMPLE", + "retrieveMaxEntries": 8, + "retrieveMaxChars": 6000, + "retrievePromptPreamble": "Relevant memories from Memind.", + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65, + } + + class FakeClient: + pass + + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + with mock.patch.object(retrieve, "state_root", return_value=state_dir): + with mock.patch.object(retrieve, "load_config", return_value=config): + with mock.patch.object(retrieve, "resolve_identity", return_value={"userId": "u", "agentId": "a"}): + with mock.patch.object(retrieve, "project_slug", return_value="memind-main"): + with mock.patch.object(retrieve, "MemindClient", return_value=FakeClient()): + with mock.patch.object( + retrieve, + "build_prompt_context", + return_value={ + "projectSlug": "memind-main", + "mode": "project-first", + "items": [ + { + "id": "dir-1", + "text": "Keep ids stable.", + "category": "directive", + "memindContextSource": "project", + } + ], + "insights": [], + }, + ) as build_context: + result = retrieve.handle_user_prompt_submit( + { + "hook_event_name": "UserPromptSubmit", + "cwd": tmp, + "session_id": "s1", + "prompt": "Fix payment tests", + } + ) + + build_context.assert_called_once() + args = build_context.call_args.args + self.assertEqual(args[3], "memind-main") + context = result["hookSpecificOutput"]["additionalContext"] + self.assertIn('', context) + self.assertIn("[item:dir-1 directive, project]", context) + def test_ingest_without_transcript_fails_open(self): with tempfile.TemporaryDirectory() as tmp: env = {"CLAUDE_PLUGIN_ROOT": tmp, "PYTHONPATH": str(ROOT)} diff --git a/memind-integrations/claude-code/tests/test_manifest.py b/memind-integrations/claude-code/tests/test_manifest.py index 299574c2..d0d66135 100644 --- a/memind-integrations/claude-code/tests/test_manifest.py +++ b/memind-integrations/claude-code/tests/test_manifest.py @@ -57,6 +57,10 @@ def test_hooks_json_shape(self): def test_default_settings(self): settings = json.loads((ROOT / "settings.json").read_text()) self.assertEqual(settings["retrieveContextTurns"], 0) + self.assertFalse(settings["autoPromptContext"]) + self.assertEqual(settings["promptContextProjectMinEntries"], 4) + self.assertEqual(settings["promptContextGlobalFallbackEntries"], 3) + self.assertEqual(settings["promptContextGlobalFallbackMinScore"], 0.65) self.assertTrue(settings["autoIngestAgentTimeline"]) self.assertNotIn("agentIdMode", settings) self.assertNotIn("autoIngest", settings) diff --git a/memind-integrations/claude-code/tests/test_prompt_context.py b/memind-integrations/claude-code/tests/test_prompt_context.py new file mode 100644 index 00000000..c269a145 --- /dev/null +++ b/memind-integrations/claude-code/tests/test_prompt_context.py @@ -0,0 +1,358 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts")) + + +class PromptContextTests(unittest.TestCase): + def test_project_hits_skip_global_fallback(self): + from scripts.lib.prompt_context import build_prompt_context + + class FakeResponse: + def __init__(self, items=None, insights=None): + self.items = items or [] + self.insights = insights or [] + self.raw_data = [] + + def model_dump(self, by_alias=True): + return { + "items": self.items, + "insights": self.insights, + "rawData": self.raw_data, + } + + class FakeClient: + def __init__(self): + self.calls = [] + + def retrieve(self, *args, **kwargs): + self.calls.append(kwargs) + return FakeResponse( + items=[ + { + "id": "p1", + "text": "Project directive", + "category": "directive", + "metadata": {"projectSlug": "memind-main"}, + "finalScore": 0.9, + }, + { + "id": "p2", + "text": "Project resolution", + "category": "resolution", + "metadata": {"projectSlug": "memind-main"}, + "finalScore": 0.8, + }, + ], + insights=[ + {"id": "i1", "text": "Project insight", "tier": "root"}, + {"id": "i2", "text": "Project branch", "tier": "branch"}, + ], + ) + + client = FakeClient() + result = build_prompt_context( + client, + {"userId": "u", "agentId": "a"}, + "fix retrieval", + "memind-main", + { + "retrieveStrategy": "SIMPLE", + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65, + }, + ) + + self.assertEqual(len(client.calls), 1) + self.assertEqual(client.calls[0]["metadata_filter"]["all"][0]["path"], "projectSlug") + self.assertEqual(client.calls[0]["metadata_filter"]["all"][0]["value"], "memind-main") + self.assertEqual(result["mode"], "project-first") + self.assertEqual(result["projectSlug"], "memind-main") + self.assertTrue(all(item["memindContextSource"] == "project" for item in result["items"])) + self.assertTrue(all(insight["memindContextSource"] == "project" for insight in result["insights"])) + + def test_sparse_project_hits_use_bounded_global_fallback(self): + from scripts.lib.prompt_context import build_prompt_context + + class FakeResponse: + def __init__(self, items=None, insights=None): + self.items = items or [] + self.insights = insights or [] + self.raw_data = [] + + def model_dump(self, by_alias=True): + return { + "items": self.items, + "insights": self.insights, + "rawData": self.raw_data, + } + + class FakeClient: + def __init__(self): + self.calls = [] + + def retrieve(self, *args, **kwargs): + self.calls.append(kwargs) + if len(self.calls) == 1: + return FakeResponse( + items=[ + { + "id": "same", + "text": "Project directive", + "category": "directive", + "metadata": {"projectSlug": "memind-main"}, + "finalScore": 0.9, + } + ], + ) + return FakeResponse( + items=[ + { + "id": "same", + "text": "Duplicate project directive", + "category": "directive", + "metadata": {"projectSlug": "memind-main"}, + "finalScore": 0.95, + }, + { + "id": "g1", + "text": "User prefers Chinese replies", + "category": "behavior", + "metadata": {}, + "finalScore": 0.91, + }, + { + "id": "s1", + "text": "Run both integration tests after hook edits", + "category": "tool", + "metadata": {"projectSlug": "other-project"}, + "finalScore": 0.8, + }, + { + "id": "low", + "text": "Weak unrelated memory", + "category": "event", + "metadata": {}, + "finalScore": 0.2, + }, + ], + insights=[ + {"id": "gi1", "text": "Shared testing insight", "tier": "root", "metadata": {}} + ], + ) + + client = FakeClient() + result = build_prompt_context( + client, + {"userId": "u", "agentId": "a"}, + "fix retrieval", + "memind-main", + { + "retrieveStrategy": "SIMPLE", + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65, + }, + ) + + self.assertEqual(len(client.calls), 2) + self.assertIsNone(client.calls[1].get("metadata_filter")) + item_ids = [item["id"] for item in result["items"]] + self.assertEqual(item_ids, ["same", "g1", "s1"]) + sources = {item["id"]: item["memindContextSource"] for item in result["items"]} + self.assertEqual(sources["same"], "project") + self.assertEqual(sources["g1"], "global") + self.assertEqual(sources["s1"], "shared") + self.assertNotIn("low", item_ids) + self.assertEqual(result["insights"][0]["memindContextSource"], "global") + + def test_dict_retrieve_response_is_supported(self): + from scripts.lib.prompt_context import build_prompt_context + + class FakeClient: + def __init__(self): + self.calls = [] + + def retrieve(self, *args, **kwargs): + self.calls.append(kwargs) + return { + "items": [ + { + "id": "p1", + "text": "Project directive", + "category": "directive", + "metadata": {"projectSlug": "memind-main"}, + "finalScore": 0.9, + } + ], + "insights": [{"id": "i1", "text": "Project insight", "tier": "root"}], + "rawData": [], + } + + result = build_prompt_context( + FakeClient(), + {"userId": "u", "agentId": "a"}, + "fix retrieval", + "memind-main", + { + "retrieveStrategy": "SIMPLE", + "promptContextProjectMinEntries": 2, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65, + }, + ) + + self.assertEqual([item["id"] for item in result["items"]], ["p1"]) + self.assertEqual(result["items"][0]["memindContextSource"], "project") + self.assertEqual(result["insights"][0]["memindContextSource"], "project") + + def test_fallback_limit_is_not_consumed_by_project_duplicates(self): + from scripts.lib.prompt_context import build_prompt_context + + class FakeResponse: + def __init__(self, items=None): + self.items = items or [] + self.insights = [] + self.raw_data = [] + + def model_dump(self, by_alias=True): + return {"items": self.items, "insights": self.insights, "rawData": self.raw_data} + + class FakeClient: + def __init__(self): + self.calls = [] + + def retrieve(self, *args, **kwargs): + self.calls.append(kwargs) + if len(self.calls) == 1: + return FakeResponse( + items=[ + { + "id": "same", + "text": "Project directive", + "category": "directive", + "metadata": {"projectSlug": "memind-main"}, + "finalScore": 0.9, + } + ] + ) + return FakeResponse( + items=[ + { + "id": "same", + "text": "Duplicate without project metadata", + "category": "directive", + "metadata": {}, + "finalScore": 0.95, + }, + { + "id": "g1", + "text": "Reusable global memory one", + "category": "behavior", + "metadata": {}, + "finalScore": 0.9, + }, + { + "id": "g2", + "text": "Reusable global memory two", + "category": "tool", + "metadata": {}, + "finalScore": 0.88, + }, + ] + ) + + result = build_prompt_context( + FakeClient(), + {"userId": "u", "agentId": "a"}, + "fix retrieval", + "memind-main", + { + "retrieveStrategy": "SIMPLE", + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 2, + "promptContextGlobalFallbackMinScore": 0.65, + }, + ) + + self.assertEqual([item["id"] for item in result["items"]], ["same", "g1", "g2"]) + + def test_empty_project_entries_do_not_skip_fallback(self): + from scripts.lib.prompt_context import build_prompt_context + + class FakeResponse: + def __init__(self, items=None, insights=None): + self.items = items or [] + self.insights = insights or [] + self.raw_data = [] + + def model_dump(self, by_alias=True): + return {"items": self.items, "insights": self.insights, "rawData": self.raw_data} + + class FakeClient: + def __init__(self): + self.calls = [] + + def retrieve(self, *args, **kwargs): + self.calls.append(kwargs) + if len(self.calls) == 1: + return FakeResponse( + items=[ + {"id": "empty-item-1", "text": "", "category": "directive"}, + {"id": "empty-item-2", "category": "resolution"}, + ], + insights=[ + {"id": "empty-insight-1", "text": ""}, + {"id": "empty-insight-2"}, + ], + ) + return FakeResponse( + items=[ + { + "id": "g1", + "text": "Reusable global memory", + "category": "behavior", + "metadata": {}, + "finalScore": 0.9, + } + ] + ) + + client = FakeClient() + result = build_prompt_context( + client, + {"userId": "u", "agentId": "a"}, + "fix retrieval", + "memind-main", + { + "retrieveStrategy": "SIMPLE", + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65, + }, + ) + + self.assertEqual(len(client.calls), 2) + self.assertEqual([item["id"] for item in result["items"] if item.get("text")], ["g1"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/memind-integrations/codex/README.md b/memind-integrations/codex/README.md index 36c6fba7..3a75a944 100644 --- a/memind-integrations/codex/README.md +++ b/memind-integrations/codex/README.md @@ -1,8 +1,8 @@ # Memind Codex Integration -Memind adds persistent project memory to Codex CLI. The integration retrieves relevant Memind context before -each user prompt and submits Codex coding-agent timelines through Memind's reliable extraction endpoint after -each turn. +Memind adds persistent project memory to Codex CLI. The integration injects project continuity context at session +start, can inject exact file/tool context before high-value tools, and submits Codex coding-agent timelines through +Memind's reliable extraction endpoint after each turn. Use this integration when you want Codex to remember project facts, preferences, and previous decisions across sessions. The plugin connects Codex to an already-running Memind server; it does not start the server itself. @@ -17,8 +17,9 @@ The integration is intentionally small: ## What It Does -- **Retrieval**: `UserPromptSubmit` calls `MemindClient.memory.retrieve(...)` and injects relevant memories into - the Codex prompt as `...`. +- **Prompt context (optional)**: `UserPromptSubmit` always buffers the user prompt into the local agent timeline. + If `autoPromptContext=true`, it also retrieves project-first Memind memories with a bounded global fallback and + injects them as `...`. - **Ingestion**: `PreToolUse` and `PostToolUse` buffer normalized tool events locally. The next `Stop` hook submits them as `rawContent.type = "agent_timeline"` so Memind can extract user and agent memories from the same agent turn. @@ -121,7 +122,7 @@ The installed hooks are: | Codex event | Script | Timeout | Purpose | | --- | --- | ---: | --- | | `SessionStart` | `scripts/session_start.py` | 5s | Replay at most one failed timeline payload, clean old state, and inject project continuity context when available. | -| `UserPromptSubmit` | `scripts/retrieve.py` | 12s | Buffer the user prompt event and retrieve relevant Memind context. | +| `UserPromptSubmit` | `scripts/retrieve.py` | 12s | Buffer the user prompt event. Optionally inject project-first prompt memory when `autoPromptContext=true`. | | `PreToolUse` | `scripts/pre_tool_use.py` | 5s | Buffer a redacted tool-start event and, for high-value file edits or commands, inject compact file/tool memory context. | | `PostToolUse` | `scripts/post_tool_use.py` | 5s | Buffer a redacted tool-result event in local session state. | | `Stop` | `scripts/ingest.py` | 15s | Flush buffered `agent_timeline` events after a turn. | @@ -147,6 +148,7 @@ User configuration is optional. Save overrides as `~/.memind/codex.json`: "agentId": "coding-agent", "sourceClient": "codex", "autoIngestAgentTimeline": true, + "autoPromptContext": false, "retrieveContextTurns": 0 } ``` @@ -166,7 +168,8 @@ Settings are loaded in this order: | `userId` | `local__` | Memind user identity. | | `agentId` | `coding-agent` | Shared Memind agent identity. Use the same value from Claude Code, Codex, and API clients to share one coding-agent memory space. | | `sourceClient` | `codex` | Source marker stored with Memind data. | -| `autoRetrieve` | `true` | Enables prompt-time memory retrieval. | +| `autoRetrieve` | `true` | Backward-compatible broad retrieval gate used by prompt and tool retrieval paths. Leave enabled unless you want to disable retrieval-assisted contexts entirely. | +| `autoPromptContext` | `false` | Enables prompt-time `` retrieval and injection on `UserPromptSubmit`. Off by default to avoid token cost and unrelated cross-project recall. | | `autoSessionContext` | `true` | Enables SessionStart project continuity context injection. | | `autoIngestAgentTimeline` | `true` | Enables user prompt, tool/result, assistant message, and stop event buffering plus `agent_timeline` rawdata flush. | | `autoToolContext` | `true` | Enables compact PreToolUse context for high-value file edits and commands. | @@ -174,6 +177,9 @@ Settings are loaded in this order: | `retrieveMaxEntries` | `8` | Maximum formatted memory entries injected into Codex. | | `retrieveMaxChars` | `6000` | Maximum injected context characters. | | `retrieveContextTurns` | `0` | Number of recent transcript turns to include in the retrieval query. | +| `promptContextProjectMinEntries` | `4` | Minimum current-project entries before global fallback is skipped. | +| `promptContextGlobalFallbackEntries` | `3` | Maximum fallback entries from the shared memory space when current-project results are sparse. | +| `promptContextGlobalFallbackMinScore` | `0.65` | Minimum score for fallback entries. | | `toolContextMaxChars` | `3500` | Maximum injected PreToolUse context characters. | | `toolContextEntryMaxChars` | `520` | Maximum characters per PreToolUse context entry. | | `toolContextMaxItems` | `6` | Maximum exact or fallback items considered for PreToolUse context. | @@ -194,6 +200,7 @@ export MEMIND_API_TOKEN=... export MEMIND_USER_ID=local__alice export MEMIND_AGENT_ID=coding-agent export MEMIND_SOURCE_CLIENT=codex +export MEMIND_AUTO_PROMPT_CONTEXT=false export MEMIND_AUTO_SESSION_CONTEXT=true export MEMIND_AUTO_TOOL_CONTEXT=true export MEMIND_AUTO_INGEST_AGENT_TIMELINE=true @@ -206,6 +213,8 @@ export MEMIND_DEBUG=true Additional environment variables include `MEMIND_AUTO_RETRIEVE`, `MEMIND_RETRIEVE_STRATEGY`, `MEMIND_RETRIEVE_MAX_ENTRIES`, `MEMIND_RETRIEVE_MAX_CHARS`, `MEMIND_SESSION_CONTEXT_RECENT_SESSIONS`, `MEMIND_SESSION_CONTEXT_MAX_ITEMS`, +`MEMIND_PROMPT_CONTEXT_PROJECT_MIN_ENTRIES`, `MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_ENTRIES`, +`MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_MIN_SCORE`, `MEMIND_TOOL_CONTEXT_ENTRY_MAX_CHARS`, `MEMIND_TOOL_CONTEXT_MAX_ITEMS`, `MEMIND_TOOL_CONTEXT_MIN_EXACT_ITEMS`, `MEMIND_STATE_MAX_AGE_DAYS`, `MEMIND_INGEST_RETRY_SPOOL`, `MEMIND_INGEST_RETRY_MAX_FILES`, and `MEMIND_INGEST_RETRY_MAX_AGE_DAYS`. @@ -256,33 +265,50 @@ Historical Memind project memory. Use only when directly helpful. Current user i This project-continuity context is separate from prompt-time retrieval. It helps a new Codex session know what recently happened in this project before the first user prompt is handled. -## Retrieval Behavior +## Prompt Context -Retrieval runs before each user prompt when `autoRetrieve = true`. +Prompt context is disabled by default. `UserPromptSubmit` still buffers the user prompt into the local +`agent_timeline` state, but it does not inject `` unless `autoPromptContext = true`. + +Enable prompt-time recall only when you want query-aware memory on every prompt: + +```json +{ + "autoPromptContext": true, + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65 +} +``` + +When enabled, Memind first retrieves memories constrained by the current project's `metadata.projectSlug`. If those +project hits are sparse, it adds a bounded global fallback from the same `userId + agentId` memory space. The injected +context marks source provenance as `project`, `global`, or `shared`. The injected context format is: ```text - + Relevant memories from Memind. Use only when directly helpful: ## Directives -- [item:201 directive] Do not default Claude Code or Codex to conversation rawdata. +- [item:201 directive, project, 2026-05-27] Do not default Claude Code or Codex to conversation rawdata. +- [item:206 behavior, global, 2026-05-20] User prefers Chinese replies for technical discussions. ## Resolved Problems -- [item:202 resolution] Retry spool events are cleared only after successful agent_timeline extraction. +- [item:202 resolution, project, 2026-05-27] Retry spool events are cleared only after successful agent_timeline extraction. ## Agent Playbooks -- [item:203 playbook] When hooks change, run both integration test suites and git diff --check. +- [item:203 playbook, project, 2026-05-27] When hooks change, run both integration test suites and git diff --check. ## Tool Notes -- [item:204 tool] Use Python 3.12 for the Codex integration test suite. +- [item:204 tool, shared, 2026-05-18] Use Python 3.12 for the Codex integration test suite. ## Insights -- [insight:301 root] Coding-agent integrations share memory through stable userId and agentId. +- [insight:301 root, project, 2026-05-27] Coding-agent integrations share memory through stable userId and agentId. ## Memory Items -- [item:205 event] rawdata-agent emits agent_episode segment metadata. +- [item:205 event, project, 2026-05-27] rawdata-agent emits agent_episode segment metadata. ``` @@ -508,6 +534,7 @@ curl -fsSL http://127.0.0.1:8366/open/v1/health ``` - Confirm `autoRetrieve` is `true`. +- Confirm `autoPromptContext` is `true` for prompt-time `` injection. SessionStart and PreToolUse context use separate switches. - Confirm existing memories are stored under the same `userId` and `agentId`. - Try setting `retrieveContextTurns` to `1` or `2` if the current prompt is very short. diff --git a/memind-integrations/codex/install.sh b/memind-integrations/codex/install.sh index 6f049f31..3d00e4c1 100644 --- a/memind-integrations/codex/install.sh +++ b/memind-integrations/codex/install.sh @@ -192,6 +192,7 @@ download_remote_install() { "scripts/lib/content.py" "scripts/lib/identity.py" "scripts/lib/logging_utils.py" + "scripts/lib/prompt_context.py" "scripts/lib/retry.py" "scripts/lib/session_context.py" "scripts/lib/state.py" diff --git a/memind-integrations/codex/scripts/lib/config.py b/memind-integrations/codex/scripts/lib/config.py index 0004370a..ab9de4d7 100644 --- a/memind-integrations/codex/scripts/lib/config.py +++ b/memind-integrations/codex/scripts/lib/config.py @@ -23,6 +23,7 @@ "agentId": "coding-agent", "sourceClient": "codex", "autoRetrieve": True, + "autoPromptContext": False, "autoSessionContext": True, "autoIngestAgentTimeline": True, "retrieveStrategy": "SIMPLE", @@ -30,6 +31,9 @@ "retrieveMaxChars": 6000, "retrievePromptPreamble": "Relevant memories from Memind. Use only when directly helpful:", "retrieveContextTurns": 0, + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65, "autoToolContext": True, "toolContextMaxChars": 3500, "toolContextEntryMaxChars": 520, @@ -52,12 +56,16 @@ "MEMIND_AGENT_ID": ("agentId", str), "MEMIND_SOURCE_CLIENT": ("sourceClient", str), "MEMIND_AUTO_RETRIEVE": ("autoRetrieve", "bool"), + "MEMIND_AUTO_PROMPT_CONTEXT": ("autoPromptContext", "bool"), "MEMIND_AUTO_SESSION_CONTEXT": ("autoSessionContext", "bool"), "MEMIND_AUTO_INGEST_AGENT_TIMELINE": ("autoIngestAgentTimeline", "bool"), "MEMIND_RETRIEVE_STRATEGY": ("retrieveStrategy", str), "MEMIND_RETRIEVE_MAX_ENTRIES": ("retrieveMaxEntries", "int"), "MEMIND_RETRIEVE_MAX_CHARS": ("retrieveMaxChars", "int"), "MEMIND_RETRIEVE_CONTEXT_TURNS": ("retrieveContextTurns", "int_allow_zero"), + "MEMIND_PROMPT_CONTEXT_PROJECT_MIN_ENTRIES": ("promptContextProjectMinEntries", "int_allow_zero"), + "MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_ENTRIES": ("promptContextGlobalFallbackEntries", "int_allow_zero"), + "MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_MIN_SCORE": ("promptContextGlobalFallbackMinScore", "float_allow_zero"), "MEMIND_AUTO_TOOL_CONTEXT": ("autoToolContext", "bool"), "MEMIND_TOOL_CONTEXT_MAX_CHARS": ("toolContextMaxChars", "int"), "MEMIND_TOOL_CONTEXT_ENTRY_MAX_CHARS": ("toolContextEntryMaxChars", "int"), @@ -85,6 +93,13 @@ def parse_int(value, name, allow_zero=False): return parsed +def parse_float(value, name, allow_zero=False): + parsed = float(value) + if parsed < 0 or (parsed == 0 and not allow_zero): + raise ValueError(f"{name} must be positive") + return parsed + + def parse_list(value): return [part.strip() for part in str(value).split(",") if part.strip()] @@ -96,6 +111,8 @@ def _coerce(value, kind, name): return parse_int(value, name) if kind == "int_allow_zero": return parse_int(value, name, allow_zero=True) + if kind == "float_allow_zero": + return parse_float(value, name, allow_zero=True) if kind == "list": return parse_list(value) return kind(value) diff --git a/memind-integrations/codex/scripts/lib/context_compiler.py b/memind-integrations/codex/scripts/lib/context_compiler.py index d4665025..6785c24c 100644 --- a/memind-integrations/codex/scripts/lib/context_compiler.py +++ b/memind-integrations/codex/scripts/lib/context_compiler.py @@ -165,7 +165,7 @@ def compile_prompt_retrieval_context(data, config): ) rendered = _render_context( wrapper="memind_memories", - attrs={}, + attrs=_prompt_attrs(data), preamble=preamble, sections=_prepare_sections(sections, "prompt_retrieval"), order=PROMPT_SECTION_ORDER, @@ -178,7 +178,7 @@ def compile_prompt_retrieval_context(data, config): return rendered return _render_context( wrapper="memind_memories", - attrs={}, + attrs=_prompt_attrs(data), preamble=preamble, sections={}, order=PROMPT_SECTION_ORDER, @@ -190,6 +190,15 @@ def compile_prompt_retrieval_context(data, config): ) +def _prompt_attrs(data): + attrs = {} + if data.get("projectSlug"): + attrs["project"] = data["projectSlug"] + if data.get("mode"): + attrs["mode"] = data["mode"] + return attrs + + def compile_tool_context(context, config): target = context.get("target") or {} items = [_normalize_tool_item(item) for item in context.get("items") or [] if _field(item, "text")] @@ -296,6 +305,7 @@ def _normalize_retrieved_item(item): "text": _clean(_field(item, "text")), "createdAt": _field(item, "createdAt") or _field(item, "created_at"), "score": _number(_field(item, "finalScore"), _field(item, "vectorScore"), 0), + "source": _field(item, "memindContextSource"), } @@ -307,6 +317,7 @@ def _normalize_insight(insight): "text": _clean(_field(insight, "text")), "createdAt": _field(insight, "createdAt") or _field(insight, "created_at"), "score": 0, + "source": _field(insight, "memindContextSource"), } @@ -499,8 +510,13 @@ def _render_entry(entry, max_chars): label = f"insight:{entry.get('id')} {entry.get('category') or 'insight'}" else: label = f"item:{entry.get('id')} {entry.get('category') or 'memory'}" + source = entry.get("source") + label_parts = [label] + if source: + label_parts.append(source) if date: - label = f"{label}, {date}" + label_parts.append(date) + label = ", ".join(label_parts) return f"- [{label}] {_clip(entry.get('text'), max_chars)}" diff --git a/memind-integrations/codex/scripts/lib/prompt_context.py b/memind-integrations/codex/scripts/lib/prompt_context.py new file mode 100644 index 00000000..875cbba3 --- /dev/null +++ b/memind-integrations/codex/scripts/lib/prompt_context.py @@ -0,0 +1,214 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +def project_metadata_filter(project_slug): + return {"all": [{"path": "projectSlug", "op": "eq", "value": project_slug}]} + + +def build_prompt_context(client, identity, query, project_slug, config): + project_data = _retrieve( + client, + identity, + query, + config, + metadata_filter=project_metadata_filter(project_slug) if project_slug else None, + ) + _mark_sources(project_data, project_slug, default_source="project") + + project_count = _usable_entry_count(project_data, ("items", "insights")) + min_entries = int(config.get("promptContextProjectMinEntries", 4)) + fallback_limit = int(config.get("promptContextGlobalFallbackEntries", 3)) + + if project_count >= min_entries or fallback_limit <= 0: + return _shape(project_data, project_slug) + + fallback_data = _retrieve(client, identity, query, config, metadata_filter=None) + _mark_sources(fallback_data, project_slug) + project_keys = _entry_keys(project_data) + fallback_data = _filter_fallback(fallback_data, config, fallback_limit, project_keys) + + return _shape(_merge(project_data, fallback_data), project_slug) + + +def _retrieve(client, identity, query, config, metadata_filter=None): + response = client.retrieve( + identity["userId"], + identity["agentId"], + query, + config.get("retrieveStrategy", "SIMPLE"), + False, + metadata_filter=metadata_filter, + include={"raw_data_metadata": True}, + ) + return _dump_response(response) + + +def _dump_response(response): + if isinstance(response, dict): + data = response + elif hasattr(response, "model_dump"): + data = response.model_dump(by_alias=True) + else: + data = { + "items": list(getattr(response, "items", []) or []), + "insights": list(getattr(response, "insights", []) or []), + "rawData": list(getattr(response, "raw_data", []) or getattr(response, "rawData", []) or []), + } + + return { + "items": [_dump_entry(entry) for entry in data.get("items", []) or []], + "insights": [_dump_entry(entry) for entry in data.get("insights", []) or []], + "rawData": [_dump_entry(entry) for entry in data.get("rawData", []) or data.get("raw_data", []) or []], + } + + +def _dump_entry(entry): + if isinstance(entry, dict): + return dict(entry) + if hasattr(entry, "model_dump"): + return entry.model_dump(by_alias=True) + return { + key: value + for key, value in vars(entry).items() + if not key.startswith("_") + } + + +def _shape(data, project_slug): + return { + "projectSlug": project_slug, + "mode": "project-first", + "items": data.get("items") or [], + "insights": data.get("insights") or [], + "rawData": data.get("rawData") or data.get("raw_data") or [], + } + + +def _merge(project_data, fallback_data): + return { + "items": _dedupe((project_data.get("items") or []) + (fallback_data.get("items") or [])), + "insights": _dedupe((project_data.get("insights") or []) + (fallback_data.get("insights") or [])), + "rawData": _dedupe((project_data.get("rawData") or []) + (fallback_data.get("rawData") or [])), + } + + +def _filter_fallback(data, config, limit, excluded_keys=None): + min_score = float(config.get("promptContextGlobalFallbackMinScore", 0.65)) + excluded_keys = excluded_keys or set() + return { + "items": _take_fallback_entries(data.get("items", []), min_score, limit, excluded_keys), + "insights": _take_fallback_entries(data.get("insights", []), min_score, limit, excluded_keys), + "rawData": [], + } + + +def _take_fallback_entries(entries, min_score, limit, excluded_keys): + kept = [] + seen = set() + for entry in entries: + key = _entry_key(entry) + if not key or key in excluded_keys or key in seen: + continue + if entry.get("memindContextSource") == "project": + continue + if not _passes_fallback_score(entry, min_score): + continue + kept.append(entry) + seen.add(key) + if len(kept) >= limit: + break + return kept + + +def _usable_entry_count(data, buckets): + count = 0 + for bucket in buckets: + for entry in data.get(bucket) or []: + if _field(entry, "text"): + count += 1 + return count + + +def _entry_keys(data): + keys = set() + for bucket in ("items", "insights", "rawData", "raw_data"): + for entry in data.get(bucket) or []: + key = _entry_key(entry) + if key: + keys.add(key) + return keys + + +def _mark_sources(data, project_slug, default_source=None): + for key in ("items", "insights", "rawData", "raw_data"): + for entry in data.get(key) or []: + entry["memindContextSource"] = _source_for(entry, project_slug, default_source) + + +def _source_for(entry, project_slug, default_source=None): + metadata = _field(entry, "metadata") or {} + entry_project = metadata.get("projectSlug") if isinstance(metadata, dict) else None + if entry_project and project_slug and entry_project == project_slug: + return "project" + if entry_project: + return "shared" + if default_source: + return default_source + return "global" + + +def _dedupe(entries): + result = [] + seen = set() + for entry in entries: + key = _entry_key(entry) + if not key or key in seen: + continue + seen.add(key) + result.append(entry) + return result + + +def _entry_key(entry): + entry_id = _field(entry, "id") or _field(entry, "rawDataId") or _field(entry, "raw_data_id") + if entry_id: + return f"id:{entry_id}" + text = " ".join(str(_field(entry, "text") or _field(entry, "caption") or "").lower().split()) + return f"text:{text[:260]}" if text else "" + + +def _passes_fallback_score(entry, min_score): + score = _score(entry) + if score is None: + return True + return score >= min_score + + +def _score(entry): + for key in ("finalScore", "final_score", "vectorScore", "vector_score", "score"): + value = _field(entry, key) + if value is None: + continue + try: + return float(value) + except (TypeError, ValueError): + continue + return None + + +def _field(value, name): + if isinstance(value, dict): + return value.get(name) + return getattr(value, name, None) diff --git a/memind-integrations/codex/scripts/retrieve.py b/memind-integrations/codex/scripts/retrieve.py index fc6f61dd..69d13692 100644 --- a/memind-integrations/codex/scripts/retrieve.py +++ b/memind-integrations/codex/scripts/retrieve.py @@ -17,6 +17,7 @@ import json import os import sys +from pathlib import Path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) @@ -25,8 +26,9 @@ from lib.config import load_config from lib.context_compiler import compile_prompt_retrieval_context from lib.content import read_recent_context -from lib.identity import resolve_identity +from lib.identity import project_slug, resolve_identity from lib.logging_utils import debug_log +from lib.prompt_context import build_prompt_context from lib.state import SessionStateStore, state_key from ingest import state_root @@ -35,35 +37,43 @@ def _format_context(data, config): return compile_prompt_retrieval_context(data, config) +def handle_user_prompt_submit(hook_input): + config = load_config() + prompt = hook_input.get("prompt") or hook_input.get("user_prompt") or "" + hook_input["source_client"] = config.get("sourceClient") or "codex" + session_key = state_key(hook_input) + with SessionStateStore(state_root()).locked(session_key) as state: + turn_id, turn_seq = state.start_agent_turn(session_key) + seq = state.next_agent_seq() + state.append_agent_event( + normalize_user_prompt_event( + hook_input, seq, turn_id=turn_id, turn_seq=turn_seq + ) + ) + + if not config.get("autoRetrieve", True): + return {"continue": True} + if not config.get("autoPromptContext", False): + return {"continue": True} + + identity = resolve_identity(config, hook_input) + context_turns = int(config.get("retrieveContextTurns", 0)) + recent_context = read_recent_context(hook_input.get("transcript_path"), context_turns) + query = prompt if not recent_context else f"{recent_context}\ncurrent: {prompt}" + cwd = hook_input.get("cwd") or os.getcwd() + slug = project_slug(Path(cwd)) + client = MemindClient(config["memindApiUrl"], config.get("memindApiToken"), timeout=12, max_retries=0) + result = build_prompt_context(client, identity, query, slug, config) + context = _format_context(result, config) + if not context: + return {"continue": True} + return {"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": context}} + + def main(): try: hook_input = json.loads(sys.stdin.read() or "{}") - config = load_config() - prompt = hook_input.get("prompt") or hook_input.get("user_prompt") or "" - hook_input["source_client"] = config.get("sourceClient") or "codex" - session_key = state_key(hook_input) - with SessionStateStore(state_root()).locked(session_key) as state: - turn_id, turn_seq = state.start_agent_turn(session_key) - seq = state.next_agent_seq() - state.append_agent_event( - normalize_user_prompt_event( - hook_input, seq, turn_id=turn_id, turn_seq=turn_seq - ) - ) - if not config.get("autoRetrieve", True): - print(json.dumps({"continue": True})) - return - identity = resolve_identity(config, hook_input) - context_turns = int(config.get("retrieveContextTurns", 0)) - recent_context = read_recent_context(hook_input.get("transcript_path"), context_turns) - query = prompt if not recent_context else f"{recent_context}\ncurrent: {prompt}" - client = MemindClient(config["memindApiUrl"], config.get("memindApiToken"), timeout=12, max_retries=0) - result = client.retrieve(identity["userId"], identity["agentId"], query, config.get("retrieveStrategy", "SIMPLE"), False) - context = _format_context(result.model_dump(by_alias=True), config) - if not context: - print(json.dumps({"continue": True})) - return - print(json.dumps({"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": context}})) + print(json.dumps(handle_user_prompt_submit(hook_input))) except Exception as exc: try: debug_log(load_config(), "retrieve_failed", {"error": str(exc)}) diff --git a/memind-integrations/codex/settings.json b/memind-integrations/codex/settings.json index 0e38501f..d0c04c45 100644 --- a/memind-integrations/codex/settings.json +++ b/memind-integrations/codex/settings.json @@ -5,6 +5,7 @@ "agentId": "coding-agent", "sourceClient": "codex", "autoRetrieve": true, + "autoPromptContext": false, "autoSessionContext": true, "autoIngestAgentTimeline": true, "retrieveStrategy": "SIMPLE", @@ -12,6 +13,9 @@ "retrieveMaxChars": 6000, "retrievePromptPreamble": "Relevant memories from Memind. Use only when directly helpful:", "retrieveContextTurns": 0, + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65, "autoToolContext": true, "toolContextMaxChars": 3500, "toolContextEntryMaxChars": 520, diff --git a/memind-integrations/codex/tests/test_config.py b/memind-integrations/codex/tests/test_config.py index c405a183..36e76add 100644 --- a/memind-integrations/codex/tests/test_config.py +++ b/memind-integrations/codex/tests/test_config.py @@ -26,6 +26,10 @@ def test_defaults_are_codex_specific(self): self.assertEqual(config["agentId"], "coding-agent") self.assertEqual(config["sourceClient"], "codex") self.assertEqual(config["retrieveContextTurns"], 0) + self.assertFalse(DEFAULT_SETTINGS["autoPromptContext"]) + self.assertEqual(DEFAULT_SETTINGS["promptContextProjectMinEntries"], 4) + self.assertEqual(DEFAULT_SETTINGS["promptContextGlobalFallbackEntries"], 3) + self.assertEqual(DEFAULT_SETTINGS["promptContextGlobalFallbackMinScore"], 0.65) self.assertTrue(config["autoSessionContext"]) self.assertEqual(config["sessionContextRecentSessions"], 3) self.assertEqual(config["sessionContextMaxItems"], 6) @@ -91,6 +95,25 @@ def test_tool_context_env_overrides(self): self.assertEqual(config["toolContextMaxItems"], 4) self.assertEqual(config["toolContextMinExactItems"], 1) + def test_prompt_context_env_overrides(self): + root = Path(__file__).resolve().parents[1] + config = load_config( + plugin_root=root, + user_config_path=Path("/no/such/file"), + env={ + "CODEX_PLUGIN_ROOT": str(root), + "MEMIND_AUTO_PROMPT_CONTEXT": "true", + "MEMIND_PROMPT_CONTEXT_PROJECT_MIN_ENTRIES": "2", + "MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_ENTRIES": "1", + "MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_MIN_SCORE": "0.5", + }, + ) + + self.assertTrue(config["autoPromptContext"]) + self.assertEqual(config["promptContextProjectMinEntries"], 2) + self.assertEqual(config["promptContextGlobalFallbackEntries"], 1) + self.assertEqual(config["promptContextGlobalFallbackMinScore"], 0.5) + if __name__ == "__main__": unittest.main() diff --git a/memind-integrations/codex/tests/test_context_compiler.py b/memind-integrations/codex/tests/test_context_compiler.py index d18e5111..b98e5d46 100644 --- a/memind-integrations/codex/tests/test_context_compiler.py +++ b/memind-integrations/codex/tests/test_context_compiler.py @@ -223,6 +223,53 @@ def test_prompt_retrieval_context_keeps_degraded_notice(self): self.assertIn("Memory retrieval encountered an error", rendered) self.assertIn("", rendered) + def test_prompt_context_renders_project_first_attrs_and_source_labels(self): + from scripts.lib.context_compiler import compile_prompt_retrieval_context + + rendered = compile_prompt_retrieval_context( + { + "projectSlug": "memind-main", + "mode": "project-first", + "items": [ + { + "id": "dir-1", + "text": "Keep userId and agentId stable.", + "category": "directive", + "createdAt": "2026-05-27T10:00:00Z", + "finalScore": 0.9, + "memindContextSource": "project", + }, + { + "id": "beh-1", + "text": "User prefers Chinese replies.", + "category": "behavior", + "createdAt": "2026-05-20T10:00:00Z", + "finalScore": 0.88, + "memindContextSource": "global", + }, + ], + "insights": [ + { + "id": "ins-1", + "text": "Run both Claude Code and Codex tests after hook edits.", + "tier": "root", + "createdAt": "2026-05-18T10:00:00Z", + "memindContextSource": "shared", + } + ], + }, + { + "retrieveMaxEntries": 8, + "retrieveMaxChars": 6000, + "retrievePromptPreamble": "Relevant memories from Memind.", + }, + ) + + self.assertIn('', rendered) + self.assertIn("[item:dir-1 directive, project, 2026-05-27]", rendered) + self.assertIn("[item:beh-1 behavior, global, 2026-05-20]", rendered) + self.assertIn("[insight:ins-1 root, shared, 2026-05-18]", rendered) + def test_tool_context_compiler_renders_bounded_file_context(self): from scripts.lib.context_compiler import compile_tool_context diff --git a/memind-integrations/codex/tests/test_hooks.py b/memind-integrations/codex/tests/test_hooks.py index 154f850c..0473fe43 100644 --- a/memind-integrations/codex/tests/test_hooks.py +++ b/memind-integrations/codex/tests/test_hooks.py @@ -120,6 +120,105 @@ def test_format_context_groups_agent_memory_categories(self): self.assertLess(context.index("## Resolved Problems"), context.index("## Agent Playbooks")) self.assertNotIn("## Memory Items", context) + def test_retrieve_default_does_not_call_memind_but_buffers_prompt(self): + sys.path.insert(0, str(ROOT / "scripts")) + import retrieve + + retrieve = importlib.reload(retrieve) + + config = { + "sourceClient": "codex", + "autoRetrieve": True, + "autoPromptContext": False, + "retrieveContextTurns": 0, + } + + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + with mock.patch.object(retrieve, "state_root", return_value=state_dir): + with mock.patch.object(retrieve, "load_config", return_value=config): + with mock.patch.object(retrieve, "MemindClient") as client_cls: + result = retrieve.handle_user_prompt_submit( + { + "hook_event_name": "UserPromptSubmit", + "cwd": tmp, + "session_id": "s1", + "user_prompt": "Fix payment tests", + } + ) + + self.assertEqual(result, {"continue": True}) + client_cls.assert_not_called() + state_file = next(state_dir.glob("*.json")) + event = json.loads(state_file.read_text())["agentEvents"][0] + self.assertEqual(event["kind"], "user_prompt") + self.assertEqual(event["text"], "Fix payment tests") + + def test_retrieve_prompt_context_enabled_uses_project_first_context(self): + sys.path.insert(0, str(ROOT / "scripts")) + import retrieve + + retrieve = importlib.reload(retrieve) + + config = { + "sourceClient": "codex", + "memindApiUrl": "http://127.0.0.1:8366", + "memindApiToken": None, + "autoRetrieve": True, + "autoPromptContext": True, + "retrieveContextTurns": 0, + "retrieveStrategy": "SIMPLE", + "retrieveMaxEntries": 8, + "retrieveMaxChars": 6000, + "retrievePromptPreamble": "Relevant memories from Memind.", + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65, + } + + class FakeClient: + pass + + with tempfile.TemporaryDirectory() as tmp: + state_dir = Path(tmp) / "state" + with mock.patch.object(retrieve, "state_root", return_value=state_dir): + with mock.patch.object(retrieve, "load_config", return_value=config): + with mock.patch.object(retrieve, "resolve_identity", return_value={"userId": "u", "agentId": "a"}): + with mock.patch.object(retrieve, "project_slug", return_value="memind-main"): + with mock.patch.object(retrieve, "MemindClient", return_value=FakeClient()): + with mock.patch.object( + retrieve, + "build_prompt_context", + return_value={ + "projectSlug": "memind-main", + "mode": "project-first", + "items": [ + { + "id": "dir-1", + "text": "Keep ids stable.", + "category": "directive", + "memindContextSource": "project", + } + ], + "insights": [], + }, + ) as build_context: + result = retrieve.handle_user_prompt_submit( + { + "hook_event_name": "UserPromptSubmit", + "cwd": tmp, + "session_id": "s1", + "user_prompt": "Fix payment tests", + } + ) + + build_context.assert_called_once() + args = build_context.call_args.args + self.assertEqual(args[3], "memind-main") + context = result["hookSpecificOutput"]["additionalContext"] + self.assertIn('', context) + self.assertIn("[item:dir-1 directive, project]", context) + def test_retrieve_fail_open_when_memind_unavailable(self): with tempfile.TemporaryDirectory() as tmp: env = { diff --git a/memind-integrations/codex/tests/test_installer.py b/memind-integrations/codex/tests/test_installer.py index 23120514..ff6b7f27 100644 --- a/memind-integrations/codex/tests/test_installer.py +++ b/memind-integrations/codex/tests/test_installer.py @@ -82,6 +82,7 @@ def test_remote_install_file_list_includes_context_compiler(self): install_script = (ROOT / "install.sh").read_text() self.assertIn('"scripts/lib/context_compiler.py"', install_script) + self.assertIn('"scripts/lib/prompt_context.py"', install_script) self.assertIn('"scripts/lib/tool_context.py"', install_script) def test_install_merges_and_reinstall_is_idempotent(self): diff --git a/memind-integrations/codex/tests/test_manifest.py b/memind-integrations/codex/tests/test_manifest.py index 2788a521..e3081d38 100644 --- a/memind-integrations/codex/tests/test_manifest.py +++ b/memind-integrations/codex/tests/test_manifest.py @@ -54,6 +54,10 @@ def test_default_settings_match_spec(self): self.assertEqual(settings["sourceClient"], "codex") self.assertTrue(settings["autoIngestAgentTimeline"]) self.assertEqual(settings["retrieveContextTurns"], 0) + self.assertFalse(settings["autoPromptContext"]) + self.assertEqual(settings["promptContextProjectMinEntries"], 4) + self.assertEqual(settings["promptContextGlobalFallbackEntries"], 3) + self.assertEqual(settings["promptContextGlobalFallbackMinScore"], 0.65) self.assertNotIn("agentIdMode", settings) self.assertNotIn("commitOnStop", settings) self.assertNotIn("autoIngest", settings) diff --git a/memind-integrations/codex/tests/test_prompt_context.py b/memind-integrations/codex/tests/test_prompt_context.py new file mode 100644 index 00000000..c269a145 --- /dev/null +++ b/memind-integrations/codex/tests/test_prompt_context.py @@ -0,0 +1,358 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts")) + + +class PromptContextTests(unittest.TestCase): + def test_project_hits_skip_global_fallback(self): + from scripts.lib.prompt_context import build_prompt_context + + class FakeResponse: + def __init__(self, items=None, insights=None): + self.items = items or [] + self.insights = insights or [] + self.raw_data = [] + + def model_dump(self, by_alias=True): + return { + "items": self.items, + "insights": self.insights, + "rawData": self.raw_data, + } + + class FakeClient: + def __init__(self): + self.calls = [] + + def retrieve(self, *args, **kwargs): + self.calls.append(kwargs) + return FakeResponse( + items=[ + { + "id": "p1", + "text": "Project directive", + "category": "directive", + "metadata": {"projectSlug": "memind-main"}, + "finalScore": 0.9, + }, + { + "id": "p2", + "text": "Project resolution", + "category": "resolution", + "metadata": {"projectSlug": "memind-main"}, + "finalScore": 0.8, + }, + ], + insights=[ + {"id": "i1", "text": "Project insight", "tier": "root"}, + {"id": "i2", "text": "Project branch", "tier": "branch"}, + ], + ) + + client = FakeClient() + result = build_prompt_context( + client, + {"userId": "u", "agentId": "a"}, + "fix retrieval", + "memind-main", + { + "retrieveStrategy": "SIMPLE", + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65, + }, + ) + + self.assertEqual(len(client.calls), 1) + self.assertEqual(client.calls[0]["metadata_filter"]["all"][0]["path"], "projectSlug") + self.assertEqual(client.calls[0]["metadata_filter"]["all"][0]["value"], "memind-main") + self.assertEqual(result["mode"], "project-first") + self.assertEqual(result["projectSlug"], "memind-main") + self.assertTrue(all(item["memindContextSource"] == "project" for item in result["items"])) + self.assertTrue(all(insight["memindContextSource"] == "project" for insight in result["insights"])) + + def test_sparse_project_hits_use_bounded_global_fallback(self): + from scripts.lib.prompt_context import build_prompt_context + + class FakeResponse: + def __init__(self, items=None, insights=None): + self.items = items or [] + self.insights = insights or [] + self.raw_data = [] + + def model_dump(self, by_alias=True): + return { + "items": self.items, + "insights": self.insights, + "rawData": self.raw_data, + } + + class FakeClient: + def __init__(self): + self.calls = [] + + def retrieve(self, *args, **kwargs): + self.calls.append(kwargs) + if len(self.calls) == 1: + return FakeResponse( + items=[ + { + "id": "same", + "text": "Project directive", + "category": "directive", + "metadata": {"projectSlug": "memind-main"}, + "finalScore": 0.9, + } + ], + ) + return FakeResponse( + items=[ + { + "id": "same", + "text": "Duplicate project directive", + "category": "directive", + "metadata": {"projectSlug": "memind-main"}, + "finalScore": 0.95, + }, + { + "id": "g1", + "text": "User prefers Chinese replies", + "category": "behavior", + "metadata": {}, + "finalScore": 0.91, + }, + { + "id": "s1", + "text": "Run both integration tests after hook edits", + "category": "tool", + "metadata": {"projectSlug": "other-project"}, + "finalScore": 0.8, + }, + { + "id": "low", + "text": "Weak unrelated memory", + "category": "event", + "metadata": {}, + "finalScore": 0.2, + }, + ], + insights=[ + {"id": "gi1", "text": "Shared testing insight", "tier": "root", "metadata": {}} + ], + ) + + client = FakeClient() + result = build_prompt_context( + client, + {"userId": "u", "agentId": "a"}, + "fix retrieval", + "memind-main", + { + "retrieveStrategy": "SIMPLE", + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65, + }, + ) + + self.assertEqual(len(client.calls), 2) + self.assertIsNone(client.calls[1].get("metadata_filter")) + item_ids = [item["id"] for item in result["items"]] + self.assertEqual(item_ids, ["same", "g1", "s1"]) + sources = {item["id"]: item["memindContextSource"] for item in result["items"]} + self.assertEqual(sources["same"], "project") + self.assertEqual(sources["g1"], "global") + self.assertEqual(sources["s1"], "shared") + self.assertNotIn("low", item_ids) + self.assertEqual(result["insights"][0]["memindContextSource"], "global") + + def test_dict_retrieve_response_is_supported(self): + from scripts.lib.prompt_context import build_prompt_context + + class FakeClient: + def __init__(self): + self.calls = [] + + def retrieve(self, *args, **kwargs): + self.calls.append(kwargs) + return { + "items": [ + { + "id": "p1", + "text": "Project directive", + "category": "directive", + "metadata": {"projectSlug": "memind-main"}, + "finalScore": 0.9, + } + ], + "insights": [{"id": "i1", "text": "Project insight", "tier": "root"}], + "rawData": [], + } + + result = build_prompt_context( + FakeClient(), + {"userId": "u", "agentId": "a"}, + "fix retrieval", + "memind-main", + { + "retrieveStrategy": "SIMPLE", + "promptContextProjectMinEntries": 2, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65, + }, + ) + + self.assertEqual([item["id"] for item in result["items"]], ["p1"]) + self.assertEqual(result["items"][0]["memindContextSource"], "project") + self.assertEqual(result["insights"][0]["memindContextSource"], "project") + + def test_fallback_limit_is_not_consumed_by_project_duplicates(self): + from scripts.lib.prompt_context import build_prompt_context + + class FakeResponse: + def __init__(self, items=None): + self.items = items or [] + self.insights = [] + self.raw_data = [] + + def model_dump(self, by_alias=True): + return {"items": self.items, "insights": self.insights, "rawData": self.raw_data} + + class FakeClient: + def __init__(self): + self.calls = [] + + def retrieve(self, *args, **kwargs): + self.calls.append(kwargs) + if len(self.calls) == 1: + return FakeResponse( + items=[ + { + "id": "same", + "text": "Project directive", + "category": "directive", + "metadata": {"projectSlug": "memind-main"}, + "finalScore": 0.9, + } + ] + ) + return FakeResponse( + items=[ + { + "id": "same", + "text": "Duplicate without project metadata", + "category": "directive", + "metadata": {}, + "finalScore": 0.95, + }, + { + "id": "g1", + "text": "Reusable global memory one", + "category": "behavior", + "metadata": {}, + "finalScore": 0.9, + }, + { + "id": "g2", + "text": "Reusable global memory two", + "category": "tool", + "metadata": {}, + "finalScore": 0.88, + }, + ] + ) + + result = build_prompt_context( + FakeClient(), + {"userId": "u", "agentId": "a"}, + "fix retrieval", + "memind-main", + { + "retrieveStrategy": "SIMPLE", + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 2, + "promptContextGlobalFallbackMinScore": 0.65, + }, + ) + + self.assertEqual([item["id"] for item in result["items"]], ["same", "g1", "g2"]) + + def test_empty_project_entries_do_not_skip_fallback(self): + from scripts.lib.prompt_context import build_prompt_context + + class FakeResponse: + def __init__(self, items=None, insights=None): + self.items = items or [] + self.insights = insights or [] + self.raw_data = [] + + def model_dump(self, by_alias=True): + return {"items": self.items, "insights": self.insights, "rawData": self.raw_data} + + class FakeClient: + def __init__(self): + self.calls = [] + + def retrieve(self, *args, **kwargs): + self.calls.append(kwargs) + if len(self.calls) == 1: + return FakeResponse( + items=[ + {"id": "empty-item-1", "text": "", "category": "directive"}, + {"id": "empty-item-2", "category": "resolution"}, + ], + insights=[ + {"id": "empty-insight-1", "text": ""}, + {"id": "empty-insight-2"}, + ], + ) + return FakeResponse( + items=[ + { + "id": "g1", + "text": "Reusable global memory", + "category": "behavior", + "metadata": {}, + "finalScore": 0.9, + } + ] + ) + + client = FakeClient() + result = build_prompt_context( + client, + {"userId": "u", "agentId": "a"}, + "fix retrieval", + "memind-main", + { + "retrieveStrategy": "SIMPLE", + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65, + }, + ) + + self.assertEqual(len(client.calls), 2) + self.assertEqual([item["id"] for item in result["items"] if item.get("text")], ["g1"]) + + +if __name__ == "__main__": + unittest.main() From 18044b8a936977b97d38cbea2143fe4e9fae1910 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 17:57:23 +0800 Subject: [PATCH 50/54] docs: remove superpowers planning artifacts --- ...2026-05-24-rawdata-agent-implementation.md | 2325 -------------- .../2026-05-26-agent-hook-journal-pipeline.md | 1635 ---------- .../2026-05-27-agent-context-compiler.md | 1406 --------- .../2026-05-28-agent-pre-tool-use-context.md | 2733 ----------------- .../2026-05-28-agent-prompt-context-policy.md | 1552 ---------- ...-05-28-rawdata-agent-toolcall-telemetry.md | 1239 -------- .../specs/2026-05-24-rawdata-agent-design.md | 1420 --------- 7 files changed, 12310 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-24-rawdata-agent-implementation.md delete mode 100644 docs/superpowers/plans/2026-05-26-agent-hook-journal-pipeline.md delete mode 100644 docs/superpowers/plans/2026-05-27-agent-context-compiler.md delete mode 100644 docs/superpowers/plans/2026-05-28-agent-pre-tool-use-context.md delete mode 100644 docs/superpowers/plans/2026-05-28-agent-prompt-context-policy.md delete mode 100644 docs/superpowers/plans/2026-05-28-rawdata-agent-toolcall-telemetry.md delete mode 100644 docs/superpowers/specs/2026-05-24-rawdata-agent-design.md diff --git a/docs/superpowers/plans/2026-05-24-rawdata-agent-implementation.md b/docs/superpowers/plans/2026-05-24-rawdata-agent-implementation.md deleted file mode 100644 index 999b0de6..00000000 --- a/docs/superpowers/plans/2026-05-24-rawdata-agent-implementation.md +++ /dev/null @@ -1,2325 +0,0 @@ -# rawdata-agent Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a canonical `rawdata-agent` RawData plugin so Memind can ingest coding-agent timelines, derive episode evidence, extract AGENT memories, and feed existing Insight Tree, graph, and retrieval paths. - -**Architecture:** Implement `agent_timeline` as a new RawContent plugin, not a parallel observation database. The plugin produces deterministic `agent_episode` segments, AGENT-scoped `TOOL`, `RESOLUTION`, `PLAYBOOK`, and `DIRECTIVE` items, and optional current-core graph hints through `ExtractedMemoryEntry.graphHints()`. - -**Tech Stack:** Java 21, Maven, Reactor, Spring Boot auto-configuration, Jackson raw content subtype registration, Memind core RawData/MemoryItem/Insight/Graph APIs, Python/Java/TypeScript clients, Python Claude Code/Codex integrations. - ---- - -## Source Spec - -Implement against [2026-05-24-rawdata-agent-design.md](/Users/zhengyate/dev/openmemind/memind/docs/superpowers/specs/2026-05-24-rawdata-agent-design.md). If this plan and the spec conflict, pause and update the plan before coding. - -## Implementation Order - -1. Core support: `tools` insight type, migration-safe default reconciliation, shared graph hint converter, retrieval metadata. -2. New `memind-plugin-rawdata-agent` module: schema, redaction, episode assembly, processor, extractor. -3. Spring Boot starter and server registration. -4. Client convenience wrappers and typed response metadata. -5. Claude Code and Codex timeline capture and retrieval formatting. -6. Cross-store, integration, and evaluation verification. - -## File Structure - -### Core - -- Modify `memind-core/src/main/java/com/openmemind/ai/memory/core/data/DefaultInsightTypes.java` - - Add built-in AGENT `tools` branch insight type. -- Modify `memind-core/src/test/java/com/openmemind/ai/memory/core/data/DefaultInsightTypesTest.java` - - Assert `tools` exists, maps to `tool`, and is AGENT scoped. -- Create `memind-core/src/main/java/com/openmemind/ai/memory/core/store/insight/DefaultInsightTypeReconciler.java` - - Idempotently adds missing built-in insight types without deleting or overwriting user-defined types. -- Create `memind-core/src/test/java/com/openmemind/ai/memory/core/store/insight/DefaultInsightTypeReconcilerTest.java` - - Proves missing `tools` is inserted and customized existing insight types are preserved. -- Modify `memind-core/src/main/java/com/openmemind/ai/memory/core/store/InMemoryMemoryStore.java` - - Use the reconciler at startup. -- Modify each JDBC store constructor: - - `memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-sqlite/src/main/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStore.java` - - `memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-mysql/src/main/java/com/openmemind/ai/memory/plugin/jdbc/mysql/MysqlMemoryStore.java` - - `memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-postgresql/src/main/java/com/openmemind/ai/memory/plugin/jdbc/postgresql/PostgresqlMemoryStore.java` - - Replace seed-only behavior with migration-safe reconciliation. -- Add tests in each store test class proving existing stores receive `tools`: - - `memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-sqlite/src/test/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStoreTest.java` - - `memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-mysql/src/test/java/com/openmemind/ai/memory/plugin/jdbc/mysql/MysqlMemoryStoreTest.java` - - `memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-postgresql/src/test/java/com/openmemind/ai/memory/plugin/jdbc/postgresql/PostgresqlMemoryStoreTest.java` -- Create `memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedGraphHintConverter.java` - - Shared, public support converter from `MemoryItemExtractionResponse.ExtractedItem` entities/causal relations to `ExtractedGraphHints`. -- Modify `memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/strategy/LlmItemExtractionStrategy.java` - - Use `ExtractedGraphHintConverter` instead of private local conversion helpers. -- Add tests: - - `memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedGraphHintConverterTest.java` - - Update existing `LlmItemExtractionStrategy` tests if private helper expectations move. -- Modify `memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/ScoredResult.java` - - Add optional category and metadata for ITEM results while keeping existing constructors. -- Modify item retrieval paths that construct ITEM `ScoredResult`: - - `memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/tier/ItemTierRetriever.java` - - `memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/strategy/SimpleRetrievalStrategy.java` - - `memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/strategy/DeepRetrievalStrategy.java` - - `memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/temporal/DefaultTemporalItemChannel.java` - - `memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/graph/DefaultRetrievalGraphAssistant.java` - - `memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/graph/GraphExpansionEngine.java` - - Preserve metadata through rerank/merge copies. -- Modify server/client retrieval views to expose returned item category and metadata: - - `memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/response/RetrieveMemoryResponse.java` - - `memind-server/src/main/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryApplicationService.java` - - `memind-clients/python/src/memind/types/memory.py` - - `memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/response/RetrieveMemoryResponse.java` - - `memind-clients/typescript/src/types/memory.ts` - -### rawdata-agent Plugin - -- Create module: - - `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/pom.xml` -- Modify module lists: - - `memind-plugins/memind-plugin-rawdatas/pom.xml` - - Root/parent module list only if it explicitly enumerates rawdata child modules. -- Create package root `com.openmemind.ai.memory.plugin.rawdata.agent`. -- Create: - - `AgentRawContentTypeRegistrar.java` - - `content/AgentTimelineContent.java` - - `model/AgentTimeline.java` - - `model/AgentEvent.java` - - `model/AgentEventKind.java` - - `model/AgentEventStatus.java` - - `model/AgentProject.java` - - `model/AgentGitContext.java` - - `model/AgentEpisode.java` - - `model/AgentCommand.java` - - `model/AgentFileReference.java` - - `model/AgentToolCall.java` - - `model/AgentOutcome.java` - - `config/AgentChunkingOptions.java` - - `config/AgentExtractionOptions.java` - - `config/AgentPrivacyOptions.java` - - `config/AgentRawDataOptions.java` - - `privacy/SecretPatternRedactor.java` - - `privacy/AgentEventRedactor.java` - - `chunk/AgentEpisodeAssembler.java` - - `chunk/AgentSegmentFormatter.java` - - `chunk/AgentTimelineChunker.java` - - `caption/AgentCaptionGenerator.java` - - `processor/AgentTimelineContentProcessor.java` - - `item/AgentItemExtractionStrategy.java` - - `item/AgentItemPrompts.java` - - `item/AgentMemoryItemFactory.java` - - `plugin/AgentRawDataPlugin.java` -- Create tests under matching paths for each public behavior. - -### rawdata-agent Starter - -- Create: - - `memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter/pom.xml` - - `src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/AgentRawDataAutoConfiguration.java` - - `src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/AgentRawDataProperties.java` - - `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` - - `src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/AgentRawDataAutoConfigurationTest.java` -- Modify: - - `memind-plugins/memind-plugin-spring-boot-starters/pom.xml` - - `memind-server/pom.xml` - - `memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerApplicationTest.java` - -### Clients - -- Python: - - `memind-clients/python/src/memind/types/message.py` - - `memind-clients/python/src/memind/types/memory.py` - - `memind-clients/python/src/memind/resources/memory.py` - - `memind-clients/python/src/memind/resources/async_memory.py` - - Tests in `memind-clients/python/tests/`. -- Java: - - Keep `MapRawContent` path documented. - - Optionally add typed classes under `memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/model/common/`. - - Tests in `memind-clients/java/memind-client/src/test/java/com/openmemind/ai/client/model/common/`. -- TypeScript: - - Add `AgentTimelineContent` type aliases in `memind-clients/typescript/src/types/message.ts`. - - Add serialization tests. - -### Claude Code and Codex Integrations - -- Shared concepts are implemented independently in each integration because current directories are standalone. -- Claude Code: - - `memind-integrations/claude-code/hooks/hooks.json` - - `memind-integrations/claude-code/scripts/pre_tool_use.py` - - `memind-integrations/claude-code/scripts/post_tool_use.py` - - `memind-integrations/claude-code/scripts/lib/agent_timeline.py` - - `memind-integrations/claude-code/scripts/lib/state.py` - - `memind-integrations/claude-code/scripts/lib/client.py` - - `memind-integrations/claude-code/scripts/ingest.py` - - `memind-integrations/claude-code/scripts/pre_compact.py` - - `memind-integrations/claude-code/scripts/session_end.py` - - `memind-integrations/claude-code/scripts/retrieve.py` - - Tests under `memind-integrations/claude-code/tests/`. -- Codex: - - `memind-integrations/codex/hooks/hooks.json` - - `memind-integrations/codex/scripts/pre_tool_use.py` - - `memind-integrations/codex/scripts/post_tool_use.py` - - `memind-integrations/codex/scripts/lib/agent_timeline.py` - - `memind-integrations/codex/scripts/lib/state.py` - - `memind-integrations/codex/scripts/lib/client.py` - - `memind-integrations/codex/scripts/ingest.py` - - `memind-integrations/codex/scripts/retrieve.py` - - Tests under `memind-integrations/codex/tests/`. - ---- - -## Tasks - -### Task 1: Add Built-In `tools` Insight Type - -**Files:** -- Modify: `memind-core/src/main/java/com/openmemind/ai/memory/core/data/DefaultInsightTypes.java` -- Modify: `memind-core/src/test/java/com/openmemind/ai/memory/core/data/DefaultInsightTypesTest.java` - -- [ ] **Step 1: Write failing tests for `tools`** - -Add assertions: - -```java -@Test -@DisplayName("all() should expose tools as an agent branch insight type") -void allShouldExposeToolsAgentBranchInsightType() { - assertThat(DefaultInsightTypes.all()) - .extracting(MemoryInsightType::name) - .contains("tools"); -} - -@Test -@DisplayName("tools should map to tool category and AGENT scope") -void toolsShouldMapToToolCategoryAndAgentScope() { - assertThat(DefaultInsightTypes.tools().categories()).containsExactly("tool"); - assertThat(DefaultInsightTypes.tools().scope()).isEqualTo(MemoryScope.AGENT); - assertThat(DefaultInsightTypes.tools().insightAnalysisMode()) - .isEqualTo(com.openmemind.ai.memory.core.data.enums.InsightAnalysisMode.BRANCH); -} -``` - -- [ ] **Step 2: Run the failing test** - -Run: - -```bash -mvn -pl memind-core -Dtest=DefaultInsightTypesTest test -``` - -Expected: compilation fails because `DefaultInsightTypes.tools()` does not exist. - -- [ ] **Step 3: Add `DefaultInsightTypes.tools()`** - -Add after `resolutions()`: - -```java -public static MemoryInsightType tools() { - return new MemoryInsightType( - 28L, - "tools", - "Tool and command usage patterns. Group by stable tool name, command family," - + " invocation pattern, validation command, or repeated failure mode.", - null, - List.of("tool"), - DEFAULT_TARGET_TOKENS, - null, - null, - null, - InsightAnalysisMode.BRANCH, - null, - MemoryScope.AGENT); -} -``` - -Update `all()` to include `tools()` between `resolutions()` and root types. - -- [ ] **Step 4: Run the test** - -Run: - -```bash -mvn -pl memind-core -Dtest=DefaultInsightTypesTest test -``` - -Expected: tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add memind-core/src/main/java/com/openmemind/ai/memory/core/data/DefaultInsightTypes.java \ - memind-core/src/test/java/com/openmemind/ai/memory/core/data/DefaultInsightTypesTest.java -git commit -m "feat(core): add tools agent insight type" -``` - -### Task 2: Reconcile Built-In Insight Types for Existing Stores - -**Files:** -- Create: `memind-core/src/main/java/com/openmemind/ai/memory/core/store/insight/DefaultInsightTypeReconciler.java` -- Create: `memind-core/src/test/java/com/openmemind/ai/memory/core/store/insight/DefaultInsightTypeReconcilerTest.java` -- Modify: `memind-core/src/main/java/com/openmemind/ai/memory/core/store/InMemoryMemoryStore.java` -- Modify: `memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-sqlite/src/main/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStore.java` -- Modify: `memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-mysql/src/main/java/com/openmemind/ai/memory/plugin/jdbc/mysql/MysqlMemoryStore.java` -- Modify: `memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-postgresql/src/main/java/com/openmemind/ai/memory/plugin/jdbc/postgresql/PostgresqlMemoryStore.java` -- Test: store-specific existing test classes. - -- [ ] **Step 1: Write reconciler tests** - -Create `DefaultInsightTypeReconcilerTest`: - -```java -class DefaultInsightTypeReconcilerTest { - - @Test - void insertsMissingBuiltInTypesOnly() { - var ops = new InMemoryInsightOperations(); - ops.upsertInsightTypes(List.of(DefaultInsightTypes.identity())); - - DefaultInsightTypeReconciler.reconcile(ops); - - assertThat(ops.getInsightType("identity")).isPresent(); - assertThat(ops.getInsightType("tools")).isPresent(); - } - - @Test - void preservesExistingCustomizedType() { - var ops = new InMemoryInsightOperations(); - var customized = - DefaultInsightTypes.tools().withTargetTokens(1234); - ops.upsertInsightTypes(List.of(customized)); - - DefaultInsightTypeReconciler.reconcile(ops); - - assertThat(ops.getInsightType("tools").orElseThrow().targetTokens()) - .isEqualTo(1234); - } -} -``` - -Import: - -```java -import static org.assertj.core.api.Assertions.assertThat; - -import com.openmemind.ai.memory.core.data.DefaultInsightTypes; -import org.junit.jupiter.api.Test; -``` - -- [ ] **Step 2: Run the failing reconciler test** - -Run: - -```bash -mvn -pl memind-core -Dtest=DefaultInsightTypeReconcilerTest test -``` - -Expected: compilation fails because `DefaultInsightTypeReconciler` does not exist. - -- [ ] **Step 3: Implement reconciler** - -Create: - -```java -package com.openmemind.ai.memory.core.store.insight; - -import com.openmemind.ai.memory.core.data.DefaultInsightTypes; -import com.openmemind.ai.memory.core.data.MemoryInsightType; -import java.util.List; - -public final class DefaultInsightTypeReconciler { - - private DefaultInsightTypeReconciler() {} - - public static void reconcile(InsightOperations operations) { - if (operations == null) { - return; - } - List missing = - DefaultInsightTypes.all().stream() - .filter(type -> operations.getInsightType(type.name()).isEmpty()) - .toList(); - if (!missing.isEmpty()) { - operations.upsertInsightTypes(missing); - } - } -} -``` - -- [ ] **Step 4: Use reconciler in stores** - -Change each store constructor from: - -```java -if (initResult.createdInsightTypeTable()) { - upsertInsightTypes(DefaultInsightTypes.all()); -} -``` - -to: - -```java -DefaultInsightTypeReconciler.reconcile(this); -``` - -For `InMemoryMemoryStore`, replace direct `DefaultInsightTypes.all()` seeding with: - -```java -DefaultInsightTypeReconciler.reconcile(insightOperations); -``` - -Add imports where needed. - -- [ ] **Step 5: Add store tests** - -In each JDBC store test, add a test equivalent to: - -```java -@Test -void constructorReconcilesMissingBuiltInInsightTypesForExistingStore() { - var store = newStore(); - assertThat(store.getInsightType("tools")).isPresent(); - - removeInsightTypeForUpgradeSimulation("tools"); - - var reopened = reopenStoreWithSameDatabase(); - - assertThat(reopened.getInsightType("tools")).isPresent(); -} -``` - -Also add a preservation test: - -```java -@Test -void constructorDoesNotOverwriteExistingCustomizedBuiltInInsightType() { - var store = newStore(); - store.upsertInsightTypes( - List.of(DefaultInsightTypes.tools().withTargetTokens(1234))); - - var reopened = reopenStoreWithSameDatabase(); - - assertThat(reopened.getInsightType("tools")).isPresent(); - assertThat(reopened.getInsightType("tools").orElseThrow().targetTokens()) - .isEqualTo(1234); -} -``` - -Use the helper methods already present in each store test class. If a class does not have `reopenStoreWithSameDatabase()`, use the same datasource instance to construct a second store. - -For SQLite, implement the upgrade simulation with the test datasource: - -```java -new NamedParameterJdbcTemplate(dataSource) - .getJdbcOperations() - .update("DELETE FROM memory_insight_type WHERE name = ?", "tools"); -``` - -For MySQL/PostgreSQL, use the same database helper style already used in the test class, but keep the SQL equivalent: - -```sql -DELETE FROM memory_insight_type WHERE name = 'tools' -``` - -- [ ] **Step 6: Run tests** - -Run: - -```bash -mvn -pl memind-core -Dtest=DefaultInsightTypeReconcilerTest test -mvn -pl memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-sqlite -Dtest=SqliteMemoryStoreTest test -mvn -pl memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-mysql -Dtest=MysqlMemoryStoreTest test -mvn -pl memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-postgresql -Dtest=PostgresqlMemoryStoreTest test -``` - -Expected: all selected tests pass. - -- [ ] **Step 7: Commit** - -```bash -git add memind-core/src/main/java/com/openmemind/ai/memory/core/store/insight/DefaultInsightTypeReconciler.java \ - memind-core/src/test/java/com/openmemind/ai/memory/core/store/insight/DefaultInsightTypeReconcilerTest.java \ - memind-core/src/main/java/com/openmemind/ai/memory/core/store/InMemoryMemoryStore.java \ - memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-sqlite/src/main/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStore.java \ - memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-mysql/src/main/java/com/openmemind/ai/memory/plugin/jdbc/mysql/MysqlMemoryStore.java \ - memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-postgresql/src/main/java/com/openmemind/ai/memory/plugin/jdbc/postgresql/PostgresqlMemoryStore.java \ - memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-sqlite/src/test/java/com/openmemind/ai/memory/plugin/jdbc/sqlite/SqliteMemoryStoreTest.java \ - memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-mysql/src/test/java/com/openmemind/ai/memory/plugin/jdbc/mysql/MysqlMemoryStoreTest.java \ - memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-postgresql/src/test/java/com/openmemind/ai/memory/plugin/jdbc/postgresql/PostgresqlMemoryStoreTest.java -git commit -m "fix(core): reconcile default insight types on startup" -``` - -### Task 3: Extract Shared Graph Hint Conversion - -**Files:** -- Create: `memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedGraphHintConverter.java` -- Create: `memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedGraphHintConverterTest.java` -- Modify: `memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/strategy/LlmItemExtractionStrategy.java` - -- [ ] **Step 1: Write converter tests** - -Create: - -```java -class ExtractedGraphHintConverterTest { - - @Test - void convertsEntitiesAndCausalRelations() { - var item = - new MemoryItemExtractionResponse.ExtractedItem( - "content", - 0.9f, - null, - null, - List.of("resolutions"), - Map.of(), - "resolution", - List.of( - new MemoryItemExtractionResponse.ExtractedEntity( - "src/payment/calc.ts", "object", 1.5f)), - List.of( - new MemoryItemExtractionResponse.ExtractedCausalRelation( - 0, 1, "enabled_by", -1.0f))); - - ExtractedGraphHints hints = ExtractedGraphHintConverter.from(item); - - assertThat(hints.entities()).hasSize(1); - assertThat(hints.entities().getFirst().name()).isEqualTo("src/payment/calc.ts"); - assertThat(hints.entities().getFirst().salience()).isEqualTo(1.0f); - assertThat(hints.causalRelations()).hasSize(1); - assertThat(hints.causalRelations().getFirst().relationType()).isEqualTo("enabled_by"); - assertThat(hints.causalRelations().getFirst().strength()).isEqualTo(0.0f); - } - - @Test - void dropsBlankEntitiesAndIncompleteCausalRelations() { - var item = - new MemoryItemExtractionResponse.ExtractedItem( - "content", - 0.9f, - null, - null, - List.of(), - Map.of(), - "tool", - List.of(new MemoryItemExtractionResponse.ExtractedEntity(" ", "object", 0.5f)), - List.of(new MemoryItemExtractionResponse.ExtractedCausalRelation(null, 1, "enabled_by", 0.5f))); - - ExtractedGraphHints hints = ExtractedGraphHintConverter.from(item); - - assertThat(hints.entities()).isEmpty(); - assertThat(hints.causalRelations()).isEmpty(); - } -} -``` - -Imports: - -```java -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; -``` - -- [ ] **Step 2: Run failing test** - -Run: - -```bash -mvn -pl memind-core -Dtest=ExtractedGraphHintConverterTest test -``` - -Expected: compilation fails because converter does not exist. - -- [ ] **Step 3: Implement converter** - -Move the conversion logic from `LlmItemExtractionStrategy` into a public final support class. The class must expose: - -```java -public static ExtractedGraphHints from(MemoryItemExtractionResponse.ExtractedItem item) -``` - -and: - -```java -public static List toEntityHints( - List entities) -public static List toCausalHints( - List causalRelations) -``` - -Keep clamp behavior: null stays null, values clamp to `[0.0, 1.0]`. Preserve alias observation conversion through `EntityAliasClass.fromWireValue(observation.aliasClass())`. - -- [ ] **Step 4: Update `LlmItemExtractionStrategy`** - -Replace: - -```java -new ExtractedGraphHints(toEntityHints(item.entities()), toCausalHints(item.causalRelations())) -``` - -with: - -```java -ExtractedGraphHintConverter.from(item) -``` - -Remove now-unused private conversion helpers from `LlmItemExtractionStrategy`. - -- [ ] **Step 5: Run tests** - -Run: - -```bash -mvn -pl memind-core -Dtest=ExtractedGraphHintConverterTest,LlmItemExtractionStrategyTest test -``` - -Expected: tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedGraphHintConverter.java \ - memind-core/src/test/java/com/openmemind/ai/memory/core/extraction/item/support/ExtractedGraphHintConverterTest.java \ - memind-core/src/main/java/com/openmemind/ai/memory/core/extraction/item/strategy/LlmItemExtractionStrategy.java -git commit -m "refactor(core): share graph hint conversion" -``` - -### Task 4: Expose Item Category and Metadata in Retrieval Responses - -**Files:** -- Modify: `memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/ScoredResult.java` -- Modify: item retriever and merge/rerank paths that copy `ScoredResult` -- Modify: `memind-server/src/main/java/com/openmemind/ai/memory/server/domain/memory/response/RetrieveMemoryResponse.java` -- Modify: `memind-server/src/main/java/com/openmemind/ai/memory/server/service/memory/OpenMemoryApplicationService.java` -- Modify: client response types in Python, Java, TypeScript. -- Tests: existing retrieval/server/client tests. - -- [ ] **Step 1: Write failing server response test** - -Add or update a server service test so a retrieved item with category `TOOL` and metadata `{"toolName":"Bash"}` serializes as: - -```json -{ - "id": "1", - "text": "Use npm test payment", - "category": "tool", - "metadata": {"toolName": "Bash"} -} -``` - -Use existing `OpenMemoryApplicationService` tests if present. Otherwise add assertions to the nearest retrieve response serialization test. - -- [ ] **Step 2: Extend `ScoredResult`** - -Change record to: - -```java -public record ScoredResult( - SourceType sourceType, - String sourceId, - String text, - float vectorScore, - double finalScore, - Instant occurredAt, - String category, - Map metadata) { -``` - -Add compatible constructors matching current signatures and defaulting category to `null`, metadata to `Map.of()`. - -- [ ] **Step 3: Populate item metadata** - -Where `ScoredResult` is constructed from `MemoryItem`, pass: - -```java -item.category() == null ? null : item.category().categoryName() -item.metadata() -``` - -Where `ScoredResult` is copied by rerank, scoring, graph expansion, or time decay, preserve `category()` and `metadata()`. - -Update every constructor/copy site discovered by: - -```bash -rg -n "new ScoredResult|withOccurredAt|ScoredResult\\(" memind-core/src/main/java memind-server/src/main/java -g'*.java' -``` - -At minimum, cover these existing classes: - -```text -memind-core/src/main/java/com/openmemind/ai/memory/core/llm/rerank/LlmReranker.java -memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/ResultMerger.java -memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/RawDataAggregator.java -memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/scoring/TimeDecay.java -memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/thread/ThreadAssistMemberRanker.java -memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/tier/ItemTierRetriever.java -memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/temporal/DefaultTemporalItemChannel.java -memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/graph/DefaultRetrievalGraphAssistant.java -memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/graph/GraphExpansionEngine.java -memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/strategy/SimpleRetrievalStrategy.java -memind-core/src/main/java/com/openmemind/ai/memory/core/retrieval/strategy/DeepRetrievalStrategy.java -``` - -Any constructor from INSIGHT or RAW_DATA can keep `category = null` and `metadata = Map.of()`. Any copy of an ITEM result must preserve the original category and metadata. - -- [ ] **Step 4: Extend server response** - -Change: - -```java -public record RetrievedItemView( - String id, String text, float vectorScore, double finalScore, Instant occurredAt) {} -``` - -to include: - -```java -String category, -Map metadata -``` - -Update `toRetrievedItemView(...)` to pass `item.category()` and `item.metadata()`. - -- [ ] **Step 5: Extend clients** - -Python `RetrievedItem`: - -```python -category: str | None = None -metadata: dict[str, Any] = Field(default_factory=dict) -``` - -Java `RetrievedItem`: - -```java -String category, Map metadata -``` - -TypeScript `RetrievedItem`: - -```ts -category?: string -metadata?: Record -``` - -- [ ] **Step 6: Run tests** - -Run: - -```bash -mvn -pl memind-core -Dtest='*Retrieval*Test,*Reranker*Test' test -mvn -pl memind-server test -UV_CACHE_DIR=.uv-cache uv run --python /opt/homebrew/bin/python3.12 --extra dev pytest memind-clients/python/tests -q -mvn -pl memind-clients/java/memind-client test -PATH=/Users/zhengyate/.nvm/versions/node/v22.22.0/bin:$PATH COREPACK_HOME=/tmp/memind-corepack pnpm --dir memind-clients/typescript test -``` - -Expected: all selected tests pass. - -- [ ] **Step 7: Commit** - -```bash -git add memind-core memind-server memind-clients/python memind-clients/java/memind-client memind-clients/typescript -git commit -m "feat(retrieval): expose item category metadata" -``` - -### Task 5: Scaffold `memind-plugin-rawdata-agent` - -**Files:** -- Create: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/pom.xml` -- Modify: `memind-plugins/memind-plugin-rawdatas/pom.xml` -- Create registrar/content/model/config classes. -- Tests under `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/` - -- [ ] **Step 1: Add module POM** - -Use `memind-plugin-rawdata-toolcall/pom.xml` as the template. Artifact: - -```xml -memind-plugin-rawdata-agent -Memind - Agent RawData Plugin -``` - -Dependencies: `memind-core`, JUnit Jupiter, AssertJ, Mockito, Reactor Test. - -- [ ] **Step 2: Add module to parent** - -Add: - -```xml -memind-plugin-rawdata-agent -``` - -to `memind-plugins/memind-plugin-rawdatas/pom.xml`. - -- [ ] **Step 3: Write failing registrar/content tests** - -Create `AgentTimelineContentTest` verifying: - -```java -AgentTimelineContent content = new AgentTimelineContent( - "claude-code", - "1.0", - "session-123", - "timeline-123", - project, - events); - -assertThat(content.contentType()).isEqualTo("AGENT_TIMELINE"); -assertThat(content.toContentString()).contains("Goal:", "npm test payment"); -AgentTimelineContent duplicate = new AgentTimelineContent( - "claude-code", - "1.0", - "session-123", - "timeline-123", - project, - events); -assertThat(content.getContentId()).isEqualTo(duplicate.getContentId()); -``` - -Create `AgentRawContentTypeRegistrarTest` verifying subtype: - -```java -assertThat(new AgentRawContentTypeRegistrar().subtypes()) - .containsEntry("agent_timeline", AgentTimelineContent.class); -``` - -- [ ] **Step 4: Implement model records/classes** - -Use immutable records where possible: - -```java -public record AgentEvent( - String id, - Integer seq, - AgentEventKind kind, - Instant occurredAt, - String text, - String toolName, - String input, - String output, - AgentEventStatus status, - Long durationMs, - String path, - String operation, - String command, - Integer exitCode, - Map metadata) {} -``` - -`text` is required for `user_prompt`, `assistant_message`, `error`, and summary-like events. It must not be hidden inside `metadata`; the episode assembler uses `user_prompt.text` as the primary goal signal. - -`AgentEventKind` enum values must map lower snake JSON values: - -```java -USER_PROMPT, ASSISTANT_MESSAGE, TOOL_CALL, TOOL_RESULT, COMMAND, FILE_READ, -FILE_EDIT, TEST_RESULT, PERMISSION_REQUEST, ERROR, STOP, SESSION_END, TASK_COMPLETED -``` - -Use Jackson annotations or string parsing consistent with existing project style. - -- [ ] **Step 5: Implement `AgentTimelineContent`** - -Requirements: - -- `TYPE = "AGENT_TIMELINE"`. -- JSON raw subtype remains `"agent_timeline"` through registrar. -- `getContentId()` hashes canonical identity: - -```text -sourceClient | sessionId | timelineId | ordered event IDs | normalized event content hash -``` - -- `toContentString()` returns deterministic compact text. -- `user_prompt.text` survives Jackson round-trip and appears in `toContentString()` as the episode goal source. -- Missing optional fields are tolerated. -- Events are sorted by `seq`, then `occurredAt`, then `id`. - -- [ ] **Step 6: Run module tests** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent test -``` - -Expected: tests pass. - -- [ ] **Step 7: Commit** - -```bash -git add memind-plugins/memind-plugin-rawdatas/pom.xml \ - memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -git commit -m "feat(rawdata-agent): scaffold agent timeline content" -``` - -### Task 6: Implement Privacy Redaction - -**Files:** -- Create: `config/AgentPrivacyOptions.java` -- Create: `privacy/SecretPatternRedactor.java` -- Create: `privacy/AgentEventRedactor.java` -- Tests: `SecretPatternRedactorTest.java`, `AgentEventRedactorTest.java` - -- [ ] **Step 1: Write redaction tests** - -Test cases: - -```java -assertThat(redactor.redact("Authorization: Bearer abc.def.ghi").text()) - .contains("[REDACTED:bearer_token]"); -assertThat(redactor.redact("DATABASE_URL=postgres://u:p@example/db").text()) - .contains("[REDACTED:database_url]"); -assertThat(redactor.redact("-----BEGIN PRIVATE KEY-----\nabc").text()) - .contains("[REDACTED:private_key]"); -``` - -For event redaction: - -```java -AgentEvent event = commandWithOutput("npm test", "ok ".repeat(5000)); -AgentEvent redacted = eventRedactor.redact(event); -assertThat(redacted.output()).hasSizeLessThanOrEqualTo(4000); -assertThat(redacted.metadata()).containsEntry("redacted", true); -``` - -- [ ] **Step 2: Run failing tests** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=SecretPatternRedactorTest,AgentEventRedactorTest test -``` - -Expected: compilation fails. - -- [ ] **Step 3: Implement privacy options** - -Defaults: - -```java -redactSecrets = true -maxInputChars = 2000 -maxOutputChars = 4000 -captureFileContent = false -denyPathPatterns = List.of(".env", "*.pem", "*.key") -allowPathPatterns = List.of() -``` - -- [ ] **Step 4: Implement redactors** - -Rules: - -- Redact bearer/API tokens, common secret env vars, database URLs with credentials, private key blocks, cloud credentials. -- Truncate `input` and `output`. -- Drop file contents by default. -- Add metadata: - -```java -"redacted": true -"redactionKinds": List.of("bearer_token") -``` - -- [ ] **Step 5: Run tests** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=SecretPatternRedactorTest,AgentEventRedactorTest test -``` - -Expected: tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java \ - memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java -git commit -m "feat(rawdata-agent): redact sensitive agent events" -``` - -### Task 7: Implement Episode Assembly and Segment Formatting - -**Files:** -- Create: `chunk/AgentEpisodeAssembler.java` -- Create: `chunk/AgentSegmentFormatter.java` -- Create: `chunk/AgentTimelineChunker.java` -- Create: `caption/AgentCaptionGenerator.java` -- Tests: chunk/formatter/caption tests. - -- [ ] **Step 1: Write episode boundary tests** - -Use events: - -```text -e1 user_prompt -e2 command failed -e3 file_edit -e4 command success -e5 stop -``` - -Assert one episode with: - -```java -episode.goal() == "Fix payment tests" -episode.outcome() == AgentOutcome.SUCCESS -episode.eventIds() == ["e1","e2","e3","e4","e5"] -episode.files() contains "src/payment/calc.ts" -episode.commands() contains "npm test payment" -episode.failureSignals() contains "rounding mismatch" -``` - -Add secondary boundary tests: - -- new `user_prompt` closes previous episode. -- 31 minute gap splits episodes. -- event count over max splits. -- oversized episode splits into `investigation`, `implementation`, `validation`, `handoff`. - -- [ ] **Step 2: Write formatter test** - -Assert formatted text contains: - -```text -Goal: Fix payment tests. -Outcome: success -Files: src/payment/calc.ts -Commands: -- npm test payment -> failed: rounding mismatch -- npm test payment -> success -Evidence: -- e2: -- e4: -``` - -Assert metadata contains: - -```java -segmentType = "agent_episode" -episodeId -phase -sourceClient -sessionId -timelineId -files -commands -toolNames -failureSignals -eventIds -``` - -- [ ] **Step 3: Run failing tests** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentEpisodeAssemblerTest,AgentSegmentFormatterTest,AgentTimelineChunkerTest test -``` - -Expected: compilation fails. - -- [ ] **Step 4: Implement assembler** - -Rules: - -- Sort stable by `seq`, `occurredAt`, `id`. -- Primary start: `user_prompt`. -- Primary end: `stop`, `session_end`, `task_completed`. -- New `user_prompt` closes previous open episode. -- Secondary boundaries: `maxEventGap`, `maxEventsPerEpisode`, target token estimate, `taskId/subtaskId` change. -- Episode ID: - -```java -HashUtils.sampledSha256(sourceClient + "|" + sessionId + "|" + firstEventId + "|" + lastEventId + "|" + eventIds) -``` - -- [ ] **Step 5: Implement formatter/chunker/caption** - -`AgentTimelineChunker` must: - -- Redact before segment creation. -- Assemble episodes. -- Format deterministic segment content. -- Attach `SegmentRuntimeContext(start, end, null, sourceClient)`. - -`AgentCaptionGenerator` should return a deterministic short caption: - -```text -Agent episode: -> () -``` - -- [ ] **Step 6: Run tests** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentEpisodeAssemblerTest,AgentSegmentFormatterTest,AgentTimelineChunkerTest,AgentCaptionGeneratorTest test -``` - -Expected: tests pass. - -- [ ] **Step 7: Commit** - -```bash -git add memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java \ - memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java -git commit -m "feat(rawdata-agent): assemble agent episodes" -``` - -### Task 8: Implement Processor and Plugin Registration - -**Files:** -- Create: `processor/AgentTimelineContentProcessor.java` -- Create: `plugin/AgentRawDataPlugin.java` -- Tests: `AgentTimelineContentProcessorTest.java`, `AgentRawDataPluginTest.java` - -- [ ] **Step 1: Write processor tests** - -Assert: - -```java -assertThat(processor.contentClass()).isEqualTo(AgentTimelineContent.class); -assertThat(processor.contentType()).isEqualTo(AgentTimelineContent.TYPE); -assertThat(processor.allowedCategories()).containsExactlyInAnyOrderElementsOf(MemoryCategory.agentCategories()); -assertThat(processor.usesSourceIdentity()).isTrue(); -assertThat(processor.supportsInsight()).isTrue(); -assertThat(processor.itemExtractionStrategy()).isInstanceOf(AgentItemExtractionStrategy.class); -``` - -- [ ] **Step 2: Write plugin tests** - -Assert: - -```java -RawDataPlugin plugin = new AgentRawDataPlugin(); -assertThat(plugin.pluginId()).isEqualTo("rawdata-agent"); -assertThat(plugin.typeRegistrars()).extracting(RawContentTypeRegistrar::subtypes) - .anySatisfy(map -> assertThat(map).containsKey("agent_timeline")); -assertThat(plugin.processors(pluginContext())).hasSize(1); -``` - -- [ ] **Step 3: Run failing tests** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentTimelineContentProcessorTest,AgentRawDataPluginTest test -``` - -Expected: compilation fails. - -- [ ] **Step 4: Implement processor/plugin** - -`AgentTimelineContentProcessor` must return: - -```java -contentClass() -> AgentTimelineContent.class -contentType() -> AgentTimelineContent.TYPE -allowedCategories() -> MemoryCategory.agentCategories() -usesSourceIdentity() -> true -supportsInsight() -> true -``` - -Use: - -```java -new AgentTimelineChunker(options.chunking(), options.privacy()) -new AgentCaptionGenerator() -new AgentItemExtractionStrategy(context.chatClientRegistry().defaultClient(), context.promptRegistry(), options.extraction()) -``` - -- [ ] **Step 5: Run tests** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent test -``` - -Expected: tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -git commit -m "feat(rawdata-agent): register agent rawdata processor" -``` - -### Task 9: Implement Deterministic Agent Item Extraction - -**Files:** -- Create: `item/AgentMemoryItemFactory.java` -- Create/modify: `item/AgentItemExtractionStrategy.java` -- Tests: deterministic extractor tests. - -- [ ] **Step 1: Write deterministic extraction tests** - -For a successful episode with command failure then pass: - -```java -List entries = strategy.extract(List.of(segment), DefaultInsightTypes.all(), config).block(); - -assertThat(entries).anySatisfy(entry -> { - assertThat(entry.category()).isEqualTo("tool"); - assertThat(entry.insightTypes()).containsExactly("tools"); - assertThat(entry.metadata()).containsEntry("episodeId", "episode-123"); -}); -assertThat(entries).anySatisfy(entry -> { - assertThat(entry.category()).isEqualTo("resolution"); - assertThat(entry.insightTypes()).containsExactly("resolutions"); - assertThat(entry.metadata()).containsKey("evidenceEventIds"); -}); -``` - -For a failed unresolved episode: - -```java -assertThat(entries).noneMatch(e -> "playbook".equals(e.category())); -assertThat(entries).noneMatch(e -> "resolution".equals(e.category())); -``` - -For exact duplicate extraction: - -```java -assertThat(first.get(0).content()).isEqualTo(second.get(0).content()); -``` - -- [ ] **Step 2: Run failing tests** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentItemExtractionStrategyTest test -``` - -Expected: compilation fails or assertions fail. - -- [ ] **Step 3: Implement deterministic TOOL** - -Emit TOOL when segment metadata has `toolNames` or `commands`. - -Canonical content examples: - -```text -Use npm test payment to validate changes touching src/payment/calc.ts. -Bash command npm test payment failed once and passed once in episode episode-123. -``` - -Metadata: - -```java -toolName, command, files, commands, toolNames, successCount, failCount, -episodeId, sessionId, timelineId, sourceClient, evidenceEventIds -``` - -- [ ] **Step 4: Implement conservative deterministic RESOLUTION** - -Emit RESOLUTION only when: - -- `failureSignals` is non-empty. -- `outcome` is `success` or `partial_success`. -- There is a later successful validation command or test result. -- There is an edit, conclusion, or successful command tying the fix/conclusion to the outcome. - -Use this deterministic validation rule: - -```java -failedSignalEvent.seq < validationEvent.seq - && validationEvent.status == AgentEventStatus.SUCCESS - && (validationEvent.kind == AgentEventKind.COMMAND - || validationEvent.kind == AgentEventKind.TEST_RESULT) - && validationEvent.command matches a failed command family or known validation command -``` - -Command family matching should normalize whitespace and strip volatile arguments before comparing. For example, `npm test payment`, `npm test -- payment`, and `pnpm test payment -- --runInBand` can be grouped by the stable test target `payment`; unrelated successful commands such as `git status` must not validate a failed test. - -Canonical content: - -```text - was resolved in and validated with . -``` - -- [ ] **Step 5: Implement graph hints for deterministic items** - -Use `ExtractedGraphHints` directly: - -- file path -> `object` -- command -> `object` -- failure signal -> `concept` -- tool -> `object` - -Do not emit custom coding relation names. - -- [ ] **Step 6: Run tests** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentItemExtractionStrategyTest test -``` - -Expected: tests pass. - -- [ ] **Step 7: Commit** - -```bash -git add memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java \ - memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java -git commit -m "feat(rawdata-agent): extract deterministic agent memories" -``` - -### Task 10: Add LLM Agent Extraction for Playbooks and Directives - -**Files:** -- Create/modify: `item/AgentItemPrompts.java` -- Modify: `item/AgentItemExtractionStrategy.java` -- Tests with mocked `StructuredChatClient`. - -- [ ] **Step 1: Write mocked LLM tests** - -Mock `structuredChatClient.call(messages, MemoryItemExtractionResponse.class)` to return: - -```java -new MemoryItemExtractionResponse( - List.of( - new ExtractedItem( - "When payment tests fail with rounding mismatch, inspect policy, edit calc.ts, then run npm test payment.", - 0.86f, - null, - List.of("playbooks"), - Map.of( - "trigger", "payment tests fail with rounding mismatch", - "steps", List.of("Inspect policy", "Edit calc.ts", "Run npm test payment"), - "expectedOutcome", "payment tests pass", - "evidenceEventIds", List.of("e3", "e4", "e5")), - "playbook"))) -``` - -Assert output keeps category `playbook`, insight type `playbooks`, deterministic metadata, and evidence IDs. - -Add negative tests: - -- playbook with one step is dropped. -- resolution without fix is dropped. -- item with category `profile` is dropped. -- evidence ID not present in segment metadata is dropped. - -- [ ] **Step 2: Run failing tests** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentItemExtractionStrategyLlmTest test -``` - -Expected: tests fail. - -- [ ] **Step 3: Implement prompt builder** - -Prompt must instruct: - -- Categories limited to `tool`, `resolution`, `playbook`, `directive`. -- Every item must include `metadata.evidenceEventIds`. -- Playbooks require trigger, at least two steps, expected outcome. -- Resolutions require problem and fix/conclusion. -- Use current graph entity vocabulary only. -- Use causal relations only with `caused_by`, `enabled_by`, `motivated_by`. - -- [ ] **Step 4: Implement LLM merge/gating** - -Flow: - -1. Build deterministic baseline. -2. Call LLM only when extraction options enable it and segment meets threshold. -3. Convert `MemoryItemExtractionResponse.ExtractedItem` to `ExtractedMemoryEntry`. -4. Use `ExtractedGraphHintConverter.from(item)`. -5. Merge deterministic metadata into every LLM item. -6. Drop invalid category/insight/evidence items. -7. Produce deterministic canonical content for TOOL/RESOLUTION when possible. - -- [ ] **Step 5: Run tests** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentItemExtractionStrategyTest,AgentItemExtractionStrategyLlmTest test -``` - -Expected: tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java \ - memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java -git commit -m "feat(rawdata-agent): extract playbooks and directives" -``` - -### Task 11: Add rawdata-agent Pipeline Integration Tests - -**Files:** -- Create: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/integration/AgentExtractionPipelineIntegrationTest.java` - -- [ ] **Step 1: Write integration tests** - -Use `Memory.builder().rawDataPlugin(new AgentRawDataPlugin(AgentRawDataOptions.defaults()))` with a fake/no-op vector and mocked LLM where needed. - -Test cases: - -- Successful timeline produces TOOL item. -- Failure + edit + successful validation produces RESOLUTION item. -- Complex successful episode can produce PLAYBOOK. -- Failed unresolved episode does not produce PLAYBOOK. -- Items are AGENT categories only. -- Exact duplicate complete timeline window does not duplicate durable items. -- TOOL item metadata includes `insightTypes=["tools"]`. -- RawData metadata includes `segmentType=agent_episode`. -- RawData segment text does not include unredacted secret. - -- [ ] **Step 2: Run failing integration tests** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentExtractionPipelineIntegrationTest test -``` - -Expected: failures reveal missing wiring. - -- [ ] **Step 3: Fix pipeline wiring** - -Address: - -- Processor registered in plugin. -- `allowedCategories()` applied. -- `supportsInsight()` true. -- `tools` insight type available. -- Duplicate complete-window behavior stable. -- Redaction happens before `Segment` persistence. - -- [ ] **Step 4: Run module tests** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent test -``` - -Expected: all plugin tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java \ - memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java -git commit -m "test(rawdata-agent): cover extraction pipeline" -``` - -### Task 12: Add Spring Boot Starter and Server Registration - -**Files:** -- Create starter module and autoconfiguration files. -- Modify: `memind-plugins/memind-plugin-spring-boot-starters/pom.xml` -- Modify: `memind-server/pom.xml` -- Modify: `memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerApplicationTest.java` - -- [ ] **Step 1: Write starter test** - -`AgentRawDataAutoConfigurationTest` should assert: - -```java -assertThat(context).hasSingleBean(RawDataPlugin.class); -assertThat(context.getBean("agentRawDataPlugin")).isInstanceOf(AgentRawDataPlugin.class); -mapper.readValue("{\"type\":\"agent_timeline\",\"sourceClient\":\"claude-code\",\"sessionId\":\"s\",\"timelineId\":\"t\",\"events\":[]}", RawContent.class) - .isInstanceOf(AgentTimelineContent.class); -``` - -Add disabled property test: - -```java -.withPropertyValues("memind.rawdata.agent.enabled=false") -``` - -and assert no plugin and unsupported raw content type. - -- [ ] **Step 2: Run failing starter test** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter test -``` - -Expected: module does not exist. - -- [ ] **Step 3: Implement starter** - -Properties prefix: - -```text -memind.rawdata.agent -``` - -Expose: - -- `enabled` -- `chunking.target-episode-tokens` -- `chunking.hard-max-tokens` -- `chunking.max-events-per-episode` -- `chunking.max-event-gap` -- `extraction.extract-tool` -- `extraction.extract-resolution` -- `extraction.extract-playbook` -- `extraction.extract-directive` -- `extraction.extract-on-every-tool` -- `extraction.min-events-for-extraction` -- `extraction.min-events-for-playbook` -- `extraction.require-success-for-playbook` -- `privacy.redact-secrets` -- `privacy.max-input-chars` -- `privacy.max-output-chars` -- `privacy.capture-file-content` -- `privacy.deny-path-patterns` - -Autoconfiguration bean: - -```java -@Bean("agentRawDataPlugin") -@ConditionalOnMissingBean(name = "agentRawDataPlugin") -RawDataPlugin agentRawDataPlugin(AgentRawDataProperties properties) { - return new AgentRawDataPlugin(properties.toOptions()); -} -``` - -- [ ] **Step 4: Register server dependency** - -Add to `memind-server/pom.xml`: - -```xml - - com.openmemind.ai - memind-plugin-rawdata-agent-starter - ${revision} - -``` - -Update `MemindServerApplicationTest` to assert `agentRawDataPlugin` exists and `/extract` ObjectMapper accepts `agent_timeline`. - -- [ ] **Step 5: Run tests** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter test -mvn -pl memind-server -Dtest=MemindServerApplicationTest test -``` - -Expected: tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add memind-plugins/memind-plugin-spring-boot-starters/pom.xml \ - memind-plugins/memind-plugin-spring-boot-starters/memind-plugin-rawdata-agent-starter \ - memind-server/pom.xml \ - memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerApplicationTest.java -git commit -m "feat(rawdata-agent): add spring boot starter" -``` - -### Task 13: Add JDBC JSON Codec Coverage - -**Files:** -- Modify: `memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-core/pom.xml` -- Modify: `memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-core/src/test/java/com/openmemind/ai/memory/plugin/jdbc/internal/support/JsonCodecTest.java` - -- [ ] **Step 1: Add dependency** - -Add test/runtime dependency matching `toolcall`: - -```xml - - com.openmemind.ai - memind-plugin-rawdata-agent - ${revision} - test - -``` - -- [ ] **Step 2: Add codec test** - -Test: - -```java -void codecRoundTripsAgentTimelineWhenPluginSubtypeIsExplicitlyRegistered() { - var mapper = JsonCodec.createDefaultObjectMapper(); - RawContentJackson.registerAll(mapper, List.of(new AgentRawContentTypeRegistrar())); - var content = sampleAgentTimelineContent(); - - String json = mapper.writeValueAsString(content); - RawContent restored = mapper.readValue(json, RawContent.class); - - assertThat(restored).isInstanceOf(AgentTimelineContent.class); -} -``` - -- [ ] **Step 3: Run test** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-core -Dtest=JsonCodecTest test -``` - -Expected: passes. - -- [ ] **Step 4: Commit** - -```bash -git add memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-core/pom.xml \ - memind-plugins/memind-plugin-jdbc/memind-plugin-jdbc-core/src/test/java/com/openmemind/ai/memory/plugin/jdbc/internal/support/JsonCodecTest.java -git commit -m "test(jdbc): cover agent timeline json codec" -``` - -### Task 14: Add Client Convenience APIs - -**Files:** -- Python client resource/types/tests. -- Java client typed model or documented `MapRawContent` tests. -- TypeScript types/tests. - -- [ ] **Step 1: Python typed request test** - -Add tests: - -```python -def test_extract_agent_timeline_sends_map_raw_content(httpx_mock): - client.memory.extract_agent_timeline( - user_id="u", - agent_id="a", - timeline={ - "sourceClient": "claude-code", - "sessionId": "s", - "timelineId": "t", - "events": [], - }, - source_client="claude-code", - ) - payload = sent_json() - assert payload["rawContent"]["type"] == "agent_timeline" - assert payload["rawContent"]["sessionId"] == "s" -``` - -Add async equivalent. - -- [ ] **Step 2: Implement Python convenience wrapper** - -In sync/async resources: - -```python -def extract_agent_timeline(self, *, user_id: str, agent_id: str, timeline: dict[str, Any], source_client: str | None = None) -> ExtractMemoryResponse: - raw_content = {"type": "agent_timeline", **timeline} - return self.extract(user_id=user_id, agent_id=agent_id, raw_content=raw_content, source_client=source_client) -``` - -Do not block early adopters on typed models. - -- [ ] **Step 3: Java serialization test** - -At minimum, add a test documenting: - -```java -MapRawContent.of("agent_timeline", Map.of("sessionId", "s", "timelineId", "t", "events", List.of())) -``` - -serializes with `"type":"agent_timeline"`. - -If adding typed classes, add `AgentTimelineContent`, `AgentEvent`, and tests. - -- [ ] **Step 4: TypeScript type/serialization test** - -Add: - -```ts -const raw: RawContentValue = { - type: 'agent_timeline', - sessionId: 's', - timelineId: 't', - events: [], -} -``` - -Assert request serialization preserves the payload. - -- [ ] **Step 5: Run client tests** - -Run: - -```bash -UV_CACHE_DIR=.uv-cache uv run --python /opt/homebrew/bin/python3.12 --extra dev pytest memind-clients/python/tests -q -mvn -pl memind-clients/java/memind-client test -PATH=/Users/zhengyate/.nvm/versions/node/v22.22.0/bin:$PATH COREPACK_HOME=/tmp/memind-corepack pnpm --dir memind-clients/typescript test -``` - -Expected: tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add memind-clients/python memind-clients/java/memind-client memind-clients/typescript -git commit -m "feat(clients): add agent timeline helpers" -``` - -### Task 15: Add Claude Code Timeline Capture - -**Files:** -- Modify/create Claude Code integration files listed in File Structure. - -- [ ] **Step 1: Write timeline unit tests** - -Create `test_agent_timeline.py`: - -```python -def test_normalizes_post_tool_use_to_command_event(): - event = normalize_hook_event({ - "hook_event_name": "PostToolUse", - "session_id": "s", - "tool_name": "Bash", - "tool_input": {"command": "npm test payment"}, - "tool_response": {"exit_code": 1, "stdout": "rounding mismatch"}, - "timestamp": "2026-05-24T10:00:00Z", - }, seq=1) - assert event["kind"] == "command" - assert event["command"] == "npm test payment" - assert event["status"] == "failed" -``` - -Test redaction before spool: - -```python -assert "sk-" not in json.dumps(event) -assert "[REDACTED" in json.dumps(event) -``` - -Test flush payload: - -```python -payload = build_timeline_payload( - config={"sourceClient": "claude-code"}, - identity={"userId": "u", "agentId": "a"}, - session_id="s", - events=[event], - hook_input={"cwd": "/tmp/project"}, -) -assert payload["type"] == "agent_timeline" -assert payload["sessionId"] == "s" -assert payload["events"][0]["seq"] == 1 -``` - -- [ ] **Step 2: Implement `agent_timeline.py`** - -Functions: - -- `normalize_hook_event(hook_input: dict, seq: int) -> dict` -- `append_event(state, event)` -- `build_timeline_payload(config, identity, session_id, events, hook_input) -> dict` -- `redact_text(text: str) -> tuple[str, list[str]]` -- `event_id(source_client, session_id, seq, hook_input) -> str` - -Store only normalized, redacted fields. - -- [ ] **Step 3: Extend `SessionState`** - -Add: - -```python -self.data.setdefault("agentEvents", []) -self.data.setdefault("nextAgentSeq", 1) -``` - -Methods: - -- `append_agent_event(event)` -- `agent_events()` -- `clear_agent_events(event_ids)` -- `next_agent_seq()` - -Add a soft buffer cap to prevent very long sessions from growing state without bound: - -```python -MAX_AGENT_EVENTS = 500 - -def append_agent_event(self, event): - events = list(self.data.get("agentEvents", [])) - if any(existing.get("eventId") == event.get("eventId") for existing in events): - return - events.append(event) - if len(events) > MAX_AGENT_EVENTS: - events = events[-MAX_AGENT_EVENTS:] - self.data["agentEventsTruncated"] = True - self.data["agentEvents"] = events - self.data["updatedAt"] = time.time() -``` - -This cap is a local integration guard only. Server-side episode size is still controlled by `maxEventsPerEpisode`, `maxEventGap`, and chunking options. - -Keep transcript submitted fingerprints behavior unchanged. - -- [ ] **Step 4: Add hook scripts and hooks.json entries** - -Add `PreToolUse` and `PostToolUse` entries: - -```json -"PreToolUse": [{"hooks": [{"type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/pre_tool_use.py\"", "timeout": 5, "async": true}]}], -"PostToolUse": [{"hooks": [{"type": "command", "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/post_tool_use.py\"", "timeout": 5, "async": true}]}] -``` - -Scripts must fail open and print: - -```json -{"continue": true} -``` - -- [ ] **Step 5: Flush timeline in existing flush scripts** - -In `ingest.py`, after transcript extraction attempt, flush buffered agent events when: - -- `config.get("autoIngestAgentTimeline", True)` is true. -- Events exist. - -Call: - -```python -await client.extract(identity["userId"], identity["agentId"], timeline_payload, source_client) -``` - -On non-success/exception, spool a full extract payload: - -```json -{ - "kind": "extract", - "userId": "u", - "agentId": "a", - "sourceClient": "claude-code", - "sessionId": "s", - "eventIds": ["e1", "e2"], - "rawContent": { - "type": "agent_timeline", - "sourceClient": "claude-code", - "sessionId": "s", - "agentTurnId": "s-agent-turn-1-1", - "timelineId": "s-stop", - "events": [ - {"eventId": "e1", "seq": 1, "kind": "command", "command": "npm test payment", "status": "failed"} - ] - } -} -``` - -Only clear events after `status == "SUCCESS"`: - -```python -state.clear_agent_events([event["eventId"] for event in events]) -``` - -Update `session_start.py` retry replay so a successful `agent_timeline` extract clears buffered event IDs: - -```python -if payload.get("sessionId") and payload.get("eventIds"): - with SessionStateStore(state_root()).locked(payload["sessionId"]) as state: - state.clear_agent_events(payload["eventIds"]) -``` - -Keep existing transcript `fingerprints` replay behavior unchanged. - -Repeat flush path in `pre_compact.py` and `session_end.py`. - -- [ ] **Step 6: Run Claude Code tests** - -Run: - -```bash -PYTHONPATH=memind-integrations/claude-code/scripts python3 -m unittest discover -s memind-integrations/claude-code/tests -v -``` - -Expected: tests pass. - -- [ ] **Step 7: Commit** - -```bash -git add memind-integrations/claude-code -git commit -m "feat(claude-code): capture agent timelines" -``` - -### Task 16: Add Codex Timeline Capture - -**Files:** -- Modify/create Codex integration files listed in File Structure. - -- [ ] **Step 1: Port Claude Code timeline tests to Codex** - -Use Codex-specific environment names and payload fields. Tests must cover: - -- `PreToolUse` / `PostToolUse` fail open. -- Event normalization. -- Stable event IDs and sequence numbers. -- Stop flush sends `agent_timeline`. -- Retry spool stores full timeline payload. -- Retry replay clears `agentEvents` by `eventIds` only after Memind returns `SUCCESS`. - -- [ ] **Step 2: Implement Codex timeline buffer** - -Mirror Claude Code implementation, but keep Codex-specific hook root: - -```text -~/.memind/codex/state -~/.memind/codex/retry -``` - -and plugin env: - -```text -CODEX_PLUGIN_ROOT -``` - -Codex retry payloads use `sessionKey` instead of Claude Code `sessionId` when the existing state store uses `state_key(hook_input)`: - -```json -{ - "kind": "extract", - "userId": "u", - "agentId": "a", - "sourceClient": "codex", - "sessionKey": "codex-session-key", - "eventIds": ["e1", "e2"], - "rawContent": { - "type": "agent_timeline", - "sourceClient": "codex", - "sessionId": "codex-session-key", - "timelineId": "codex-session-key-stop", - "events": [] - } -} -``` - -Update Codex `session_start.py` retry replay equivalent so a successful `agent_timeline` extract calls: - -```python -store.clear_agent_events(payload["sessionKey"], payload["eventIds"]) -``` - -or the matching locked-state helper if the implementation keeps the same `SessionStateStore.locked(...)` shape as Claude Code. - -- [ ] **Step 3: Update hooks.json** - -Add supported hooks: - -```json -"PreToolUse": [ - { - "hooks": [ - { - "type": "command", - "command": "python3 \"${CODEX_PLUGIN_ROOT}/scripts/pre_tool_use.py\"", - "timeout": 5 - } - ] - } -], -"PostToolUse": [ - { - "hooks": [ - { - "type": "command", - "command": "python3 \"${CODEX_PLUGIN_ROOT}/scripts/post_tool_use.py\"", - "timeout": 5 - } - ] - } -] -``` - -Keep `Stop` as primary flush. - -- [ ] **Step 4: Run Codex tests** - -Run: - -```bash -PYTHONPATH=memind-integrations/codex/scripts python3 -m unittest discover -s memind-integrations/codex/tests -v -``` - -Expected: tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add memind-integrations/codex -git commit -m "feat(codex): capture agent timelines" -``` - -### Task 17: Format Retrieved AGENT Memories for Coding Hooks - -**Files:** -- Modify: `memind-integrations/claude-code/scripts/retrieve.py` -- Modify: `memind-integrations/codex/scripts/retrieve.py` -- Tests: `test_hooks.py` in both integrations. - -- [ ] **Step 1: Write formatting tests** - -Input: - -```python -data = { - "items": [ - {"id": "1", "text": "Use npm test payment", "category": "tool", "metadata": {"toolName": "Bash"}}, - {"id": "2", "text": "Payment rounding mismatch was fixed", "category": "resolution", "metadata": {}}, - {"id": "3", "text": "When payment tests fail with rounding mismatch, inspect policy, edit calc.ts, then run npm test payment.", "category": "playbook", "metadata": {}}, - {"id": "4", "text": "Do not change public API", "category": "directive", "metadata": {}}, - ], - "insights": [], -} -``` - -Assert formatted context contains: - -```text -## Agent Playbooks -## Resolved Problems -## Tool Notes -## Directives -``` - -and no unrelated in-app explanation text. - -- [ ] **Step 2: Implement formatter** - -Change `_format_context` to group item categories: - -- `playbook` -> `## Agent Playbooks` -- `resolution` -> `## Resolved Problems` -- `tool` -> `## Tool Notes` -- `directive` -> `## Directives` - -Keep existing insight-first behavior for non-agent results. Limit by `retrieveMaxEntries` and `retrieveMaxChars`. - -- [ ] **Step 3: Run tests** - -Run: - -```bash -PYTHONPATH=memind-integrations/claude-code/scripts python3 -m unittest discover -s memind-integrations/claude-code/tests -v -PYTHONPATH=memind-integrations/codex/scripts python3 -m unittest discover -s memind-integrations/codex/tests -v -``` - -Expected: tests pass. - -- [ ] **Step 4: Commit** - -```bash -git add memind-integrations/claude-code/scripts/retrieve.py \ - memind-integrations/claude-code/tests/test_hooks.py \ - memind-integrations/codex/scripts/retrieve.py \ - memind-integrations/codex/tests/test_hooks.py -git commit -m "feat(integrations): format agent memories" -``` - -### Task 18: Documentation and Examples - -**Files:** -- Modify: `memind-integrations/claude-code/README.md` -- Modify: `memind-integrations/codex/README.md` -- Modify: `memind-clients/python/README.md` -- Modify: `memind-clients/java/memind-client/README.md` if present, otherwise nearest Java client docs. -- Modify: `memind-clients/typescript/README.md` -- Create: `docs/superpowers/specs/2026-05-24-rawdata-agent-design.md` updates only if implementation discoveries changed design. - -- [ ] **Step 1: Add raw JSON example** - -Document: - -```json -{ - "userId": "local__alice", - "agentId": "claude-code__project_hash", - "sourceClient": "claude-code", - "rawContent": { - "type": "agent_timeline", - "sourceClient": "claude-code", - "sessionId": "session-123", - "timelineId": "timeline-123", - "events": [] - } -} -``` - -- [ ] **Step 2: Document configuration** - -Include: - -```properties -memind.rawdata.agent.enabled=true -memind.rawdata.agent.privacy.redact-secrets=true -memind.rawdata.agent.extraction.extract-on-every-tool=false -``` - -- [ ] **Step 3: Document limitations** - -State: - -- Exact duplicate complete windows are idempotent. -- Arbitrary overlapping partial windows are adapter responsibility in v1. -- File content capture is disabled by default. -- `rawdata-toolcall` remains supported. -- If `rawdata-toolcall` and `rawdata-agent` ingest the same tool activity, v1 may create semantically overlapping TOOL items. This is acceptable compatibility behavior; do not add cross-plugin suppression in v1. Users who want one canonical coding-agent path should enable `rawdata-agent` for full agent timelines and keep `rawdata-toolcall` for pure legacy tool-call logs. - -- [ ] **Step 4: Run docs formatting check** - -Run: - -```bash -git diff --check -- docs memind-integrations memind-clients -``` - -Expected: no whitespace errors. - -- [ ] **Step 5: Commit** - -```bash -git add docs memind-integrations memind-clients -git commit -m "docs: describe agent timeline ingestion" -``` - -### Task 19: End-to-End Server Acceptance Tests - -**Files:** -- Modify/create server integration tests: - - `memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerIntegrationTest.java` - - Or a new focused `AgentTimelineOpenApiIntegrationTest.java`. - -- [ ] **Step 1: Add Open API ingest test** - -POST to sync extract with: - -```json -{ - "userId": "u", - "agentId": "a", - "sourceClient": "claude-code", - "rawContent": { - "type": "agent_timeline", - "sourceClient": "claude-code", - "sessionId": "s", - "agentTurnId": "s-agent-turn-1-5", - "timelineId": "t", - "events": [ - {"eventId":"e1","seq":1,"kind":"user_prompt","text":"Fix payment tests","occurredAt":"2026-05-24T10:00:00Z"}, - {"eventId":"e2","seq":2,"kind":"command","toolName":"Bash","command":"npm test payment","status":"failed","output":"rounding mismatch","occurredAt":"2026-05-24T10:01:00Z"}, - {"eventId":"e3","seq":3,"kind":"file_edit","path":"src/payment/calc.ts","operation":"modify","occurredAt":"2026-05-24T10:02:00Z"}, - {"eventId":"e4","seq":4,"kind":"command","toolName":"Bash","command":"npm test payment","status":"success","occurredAt":"2026-05-24T10:03:00Z"}, - {"eventId":"e5","seq":5,"kind":"stop","occurredAt":"2026-05-24T10:04:00Z"} - ] - } -} -``` - -Assert: - -- response status is success. -- at least one item id exists. -- admin item read path shows category `tool` or `resolution`. -- rawdata metadata contains `segmentType=agent_episode`. - -- [ ] **Step 2: Add duplicate submission test** - -Submit the same request twice. Assert durable item count for that memory does not increase on second submit. - -- [ ] **Step 3: Add retrieval formatting metadata test** - -Retrieve query: - -```text -How should payment tests be validated? -``` - -Assert returned item includes: - -```json -"category": "tool", -"metadata": {"commands": ["npm test payment"]} -``` - -- [ ] **Step 4: Run server integration tests** - -Run: - -```bash -mvn -pl memind-server -Dtest=AgentTimelineOpenApiIntegrationTest test -``` - -Expected: tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add memind-server/src/test/java -git commit -m "test(server): cover agent timeline open api" -``` - -### Task 20: Evaluation Fixtures - -**Files:** -- Create: `evaluation/rawdata-agent/fixtures/auth-jwt-fix.json` -- Create: `evaluation/rawdata-agent/fixtures/payment-rounding-fix.json` -- Create: `evaluation/rawdata-agent/fixtures/project-directive.json` -- Create: `evaluation/rawdata-agent/README.md` -- Create: `evaluation/rawdata-agent/run-fixtures.py` if evaluation scripts are accepted in repo. - -- [ ] **Step 1: Add fixtures** - -Each fixture contains: - -- request payload. -- expected categories. -- expected retrieval query. -- expected key phrases. - -Example expected: - -```json -{ - "expectedCategories": ["tool", "resolution"], - "queries": [ - { - "query": "How do I validate payment calculation changes?", - "mustContain": ["npm test payment"] - } - ] -} -``` - -- [ ] **Step 2: Add runner** - -Runner should: - -1. POST fixture payload. -2. Retrieve query. -3. Check expected phrases/categories. -4. Print duplicate item rate. - -- [ ] **Step 3: Run fixture tests** - -Run: - -```bash -python3 evaluation/rawdata-agent/run-fixtures.py --base-url http://127.0.0.1:8366 -``` - -Expected when server is running: all fixtures pass. If no server is running, runner exits with a clear message and non-zero status. - -- [ ] **Step 4: Commit** - -```bash -git add evaluation/rawdata-agent -git commit -m "test(eval): add rawdata-agent fixtures" -``` - -### Task 21: Full Verification - -**Files:** all changed files. - -- [ ] **Step 1: Run formatting and whitespace checks** - -Run: - -```bash -git diff --check -mvn spotless:check -``` - -Expected: no whitespace or formatting errors. - -- [ ] **Step 2: Run Maven tests** - -Run: - -```bash -mvn test -``` - -Expected: all Java tests pass. - -- [ ] **Step 3: Run Python client tests** - -Run: - -```bash -UV_CACHE_DIR=.uv-cache uv run --python /opt/homebrew/bin/python3.12 --extra dev pytest memind-clients/python/tests -q -``` - -Expected: all Python client tests pass. - -- [ ] **Step 4: Run integration Python tests** - -Run: - -```bash -PYTHONPATH=memind-integrations/claude-code/scripts python3 -m unittest discover -s memind-integrations/claude-code/tests -v -PYTHONPATH=memind-integrations/codex/scripts python3 -m unittest discover -s memind-integrations/codex/tests -v -``` - -Expected: all integration tests pass. - -- [ ] **Step 5: Run TypeScript tests** - -Run: - -```bash -PATH=/Users/zhengyate/.nvm/versions/node/v22.22.0/bin:$PATH COREPACK_HOME=/tmp/memind-corepack pnpm --dir memind-clients/typescript test -``` - -Expected: all TypeScript client tests pass. - -- [ ] **Step 6: Build package** - -Run: - -```bash -mvn -DskipTests package -``` - -Expected: package succeeds. - -- [ ] **Step 7: Final commit** - -```bash -git status --short -git add memind-core \ - memind-plugins/memind-plugin-rawdatas \ - memind-plugins/memind-plugin-spring-boot-starters \ - memind-plugins/memind-plugin-jdbc \ - memind-server \ - memind-clients \ - memind-integrations \ - docs \ - evaluation/rawdata-agent -git commit -m "feat: add rawdata agent memory support" -``` - -Only commit if all prior verification steps pass. Before committing, run `git diff --cached --name-only` and unstage any unrelated user changes with `git restore --staged `. - ---- - -## Acceptance Checklist - -- [ ] `rawContent.type = "agent_timeline"` works through normal extraction endpoint. -- [ ] `AgentTimelineContentProcessor` returns AGENT categories, `usesSourceIdentity=true`, and `supportsInsight=true`. -- [ ] `agent_episode` is segment metadata, not a new first-class storage model. -- [ ] Redaction happens before Segment persistence, vectorization, and item extraction. -- [ ] TOOL items map to `tools` insight type. -- [ ] Existing stores get `tools` through idempotent reconciliation. -- [ ] Graph hints reuse `ExtractedMemoryEntry.graphHints()` and current entity/causal vocabulary. -- [ ] Exact duplicate complete timeline window does not create duplicate durable items. -- [ ] Claude Code and Codex capture tool events fail-open and flush complete timeline windows. -- [ ] Retrieved AGENT memories are grouped into Playbooks, Resolved Problems, Tool Notes, and Directives. -- [ ] `rawdata-toolcall` still works unchanged. diff --git a/docs/superpowers/plans/2026-05-26-agent-hook-journal-pipeline.md b/docs/superpowers/plans/2026-05-26-agent-hook-journal-pipeline.md deleted file mode 100644 index 9bd40eb4..00000000 --- a/docs/superpowers/plans/2026-05-26-agent-hook-journal-pipeline.md +++ /dev/null @@ -1,1635 +0,0 @@ -# Agent Hook Journal Pipeline Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Strengthen Memind's Claude Code and Codex integrations so coding-agent hook activity is durably captured, project-isolated by `agentId`, flushed as high-quality `agent_timeline` rawdata, and extracted by `rawdata-agent` without adding project/session concepts to Memind core. - -**Architecture:** Claude Code and Codex remain adapter layers: they normalize host-specific hook payloads into Memind `agent_event` records, persist them in a local durable journal, and flush bounded turn timelines on reliable lifecycle boundaries. The `rawdata-agent` plugin remains the canonical parser/extractor: it accepts `agent_timeline`, assembles `agent_episode` segments, generates captions, and extracts memory items through existing Memind item, insight, and graph capabilities. - -**Tech Stack:** Python 3.10+ hook scripts and unit tests, Java 21 rawdata-agent plugin, Maven, Jackson, Reactor, Memind RawData/Item/Insight/Graph APIs, Claude Code hooks, Codex hooks. - ---- - -## Scope - -This plan covers the next implementation phase only: - -- Improve hook-to-memory extraction for both `memind-integrations/claude-code` and `memind-integrations/codex`. -- Make Claude Code and Codex identities always project-scoped through `agentId`. -- Keep session, turn, timeline, and event attribution in metadata/raw content only. -- Expand `rawdata-agent` to understand the richer normalized event stream. -- Preserve the current timeline-only ingestion policy for Claude Code and Codex. - -This plan explicitly does not cover: - -- Retrieval Context Compiler changes. Prompt-time memory formatting remains out of scope for this phase. -- A Memind core project model or session model. -- An `observation` core abstraction copied from claude-mem or agentmemory. -- Conversation rawdata ingestion from Claude Code or Codex transcripts. -- LLM gate / skip heuristics before extraction. - -## Design Commitments - -Memind should learn from agentmemory's broad lifecycle capture and claude-mem's product polish, but the implementation must stay idiomatic to Memind: - -- `agent_event` is an adapter-level evidence record, not a memory item. -- `agent_timeline` is the rawdata submitted to Memind. -- `agent_episode` is the Memind rawdata segment used for captioning and item extraction. -- Project isolation uses the existing `userId + agentId` memory boundary. The project-specific suffix is part of `agentId`. -- Session and turn identifiers are metadata that improve attribution and segmentation; they do not become first-class Memind core entities. - -## End-To-End Flow - -```text -Claude Code hook payload -Codex hook payload - -> adapter-specific normalization and privacy cleanup - -> normalized agent_event - -> local durable journal/state - -> Stop / PreCompact / SessionEnd / supported lifecycle flush boundary - -> rawContent.type = "agent_timeline" - -> rawdata-agent plugin - -> agent_episode segment(s) - -> caption, vectorization, item extraction, graph hints, insight tree -``` - -For Claude Code, supported hook events in this phase are: - -```text -SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Notification, Stop, SubagentStop, PreCompact, SessionEnd -``` - -For Codex, supported hook events in this phase are the current adapter-supported set: - -```text -SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop -``` - -Do not register Claude Code-only events in Codex unless Codex explicitly supports them in the local adapter and tests. - -## File Structure - -### Claude Code Integration - -- Modify `memind-integrations/claude-code/scripts/lib/identity.py` - - Always resolve `agentId` to `__`. - - Keep `projectSlug` based on Git remote hash when available, else resolved root path hash. -- Modify `memind-integrations/claude-code/scripts/lib/config.py` - - Remove `agentIdMode` from defaults and environment mappings. -- Modify `memind-integrations/claude-code/settings.json` - - Remove `agentIdMode`. -- Modify `memind-integrations/claude-code/scripts/lib/agent_timeline.py` - - Add normalization helpers for notification, subagent stop, compact boundary, session end, and generic lifecycle events. - - Keep redaction, truncation, file/tool/command normalization, and stable event IDs. -- Modify `memind-integrations/claude-code/scripts/lib/state.py` - - Improve journal semantics for boundary preservation, flushed-event deletion, empty-state cleanup, and buffer truncation metadata. -- Modify `memind-integrations/claude-code/scripts/retrieve.py` - - Continue appending `USER_PROMPT` before retrieval. - - Ensure turn metadata is written consistently. -- Modify `memind-integrations/claude-code/scripts/pre_tool_use.py` - - Continue appending tool-start evidence. -- Modify `memind-integrations/claude-code/scripts/post_tool_use.py` - - Continue appending tool-result evidence. -- Create `memind-integrations/claude-code/scripts/notification.py` - - Append notification evidence where useful. -- Create `memind-integrations/claude-code/scripts/subagent_stop.py` - - Append subagent completion evidence. -- Modify `memind-integrations/claude-code/scripts/pre_compact.py` - - Append `compact_boundary` before flushing. -- Modify `memind-integrations/claude-code/scripts/session_end.py` - - Append `session_end` before flushing. -- Modify `memind-integrations/claude-code/scripts/ingest.py` - - Support explicit flush reasons and boundary events without duplicating stop handling. -- Modify `memind-integrations/claude-code/hooks/hooks.json` - - Register `Notification` and `SubagentStop`. - - Keep existing `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, `Stop`, `SessionEnd`. -- Modify `memind-integrations/claude-code/README.md` - - Document timeline-only ingestion, project-scoped `agentId`, supported hooks, and metadata-only sessions. -- Modify tests under `memind-integrations/claude-code/tests/`. - -### Codex Integration - -- Modify `memind-integrations/codex/scripts/lib/identity.py` - - Always resolve `agentId` to `__`. -- Modify `memind-integrations/codex/scripts/lib/config.py` - - Remove `agentIdMode` from defaults and environment mappings. -- Modify `memind-integrations/codex/settings.json` - - Remove `agentIdMode`. -- Modify `memind-integrations/codex/scripts/lib/agent_timeline.py` - - Keep normalization behavior aligned with Claude Code for the event types Codex can emit. - - Do not add Codex hook scripts or manifest entries for Claude Code-only lifecycle events. - - Generic parsing helpers may exist only when they are used by Codex tests or by shared test fixtures; they are not a Codex hook support commitment. -- Modify `memind-integrations/codex/scripts/lib/state.py` - - Align durable journal behavior with Claude Code while preserving Codex's `state_key(hook_input)` behavior. -- Modify `memind-integrations/codex/scripts/retrieve.py` - - Continue appending `USER_PROMPT` before retrieval. -- Modify `memind-integrations/codex/scripts/pre_tool_use.py` - - Continue appending tool-start evidence. -- Modify `memind-integrations/codex/scripts/post_tool_use.py` - - Continue appending tool-result evidence. -- Modify `memind-integrations/codex/scripts/ingest.py` - - Flush on `Stop` and preserve Codex-specific `sessionKey` retry payloads. -- Modify `memind-integrations/codex/hooks/hooks.json` - - Keep only `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `Stop` unless Codex support is explicitly verified in tests. -- Modify `memind-integrations/codex/scripts/install_codex_hooks.py` - - Ensure installer tests still prove idempotent merging for the supported hook set. -- Modify `memind-integrations/codex/README.md` - - Document project-scoped `agentId`, timeline-only ingestion, supported hook boundaries, and why unsupported Claude Code lifecycle hooks are not registered. -- Modify tests under `memind-integrations/codex/tests/`. - -### rawdata-agent Plugin - -- Modify `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEventKind.java` - - Add richer normalized event kinds. -- Modify `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java` - - Ensure formatting handles new event kinds without dropping evidence. -- Modify `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java` - - Segment by `USER_PROMPT -> ... -> STOP` as the preferred turn boundary. - - Treat compact/session boundaries as terminal boundaries. - - Preserve phase split behavior for oversized episodes. -- Modify `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGenerator.java` - - Include useful subagent, notification, compact, and session-end signals when present. -- Modify `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java` - - Improve deterministic tool/resolution extraction for richer event evidence. -- Modify `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java` - - Ensure LLM items validate `evidenceEventIds` against the episode event IDs. -- Modify tests under `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/`. - ---- - -## Tasks - -### Task 1: Remove `agentIdMode` And Make Agent Identity Always Project-Scoped - -**Files:** -- Modify: `memind-integrations/claude-code/scripts/lib/identity.py` -- Modify: `memind-integrations/claude-code/scripts/lib/config.py` -- Modify: `memind-integrations/claude-code/settings.json` -- Modify: `memind-integrations/claude-code/tests/test_identity.py` -- Modify: `memind-integrations/claude-code/tests/test_manifest.py` -- Modify: `memind-integrations/claude-code/tests/test_config.py` -- Modify: `memind-integrations/codex/scripts/lib/identity.py` -- Modify: `memind-integrations/codex/scripts/lib/config.py` -- Modify: `memind-integrations/codex/settings.json` -- Modify: `memind-integrations/codex/tests/test_identity.py` -- Modify: `memind-integrations/codex/tests/test_manifest.py` -- Modify: `memind-integrations/codex/tests/test_config.py` - -- [ ] **Step 1: Add failing identity tests for Claude Code** - -In `memind-integrations/claude-code/tests/test_identity.py`, replace the `agentIdMode`-dependent test with assertions that `resolve_identity()` always appends the project slug: - -```python -def test_resolve_identity_always_uses_project_slug(self): - with tempfile.TemporaryDirectory() as tmp: - identity = resolve_identity({"agentId": "claude-code"}, {"cwd": tmp}) - self.assertTrue(identity["userId"].startswith("local__")) - self.assertTrue(identity["agentId"].startswith("claude-code__")) - self.assertNotEqual(identity["agentId"], "claude-code") - self.assertNotIn(":", identity["userId"]) - self.assertNotIn(":", identity["agentId"]) - - -def test_agent_id_mode_is_ignored_for_backward_safety(self): - with tempfile.TemporaryDirectory() as tmp: - identity = resolve_identity( - {"agentId": "claude-code", "agentIdMode": "global"}, - {"cwd": tmp}, - ) - self.assertTrue(identity["agentId"].startswith("claude-code__")) -``` - -- [ ] **Step 2: Add failing identity tests for Codex** - -In `memind-integrations/codex/tests/test_identity.py`, mirror the Claude Code assertions with base agent `codex`: - -```python -def test_resolve_identity_always_uses_project_slug(self): - with tempfile.TemporaryDirectory() as tmp: - identity = resolve_identity({"agentId": "codex", "userId": "u"}, {"cwd": tmp}) - self.assertEqual(identity["userId"], "u") - self.assertTrue(identity["agentId"].startswith("codex__")) - self.assertNotEqual(identity["agentId"], "codex") - - -def test_agent_id_mode_is_ignored_for_backward_safety(self): - with tempfile.TemporaryDirectory() as tmp: - identity = resolve_identity( - {"agentId": "codex", "agentIdMode": "global", "userId": "u"}, - {"cwd": tmp}, - ) - self.assertTrue(identity["agentId"].startswith("codex__")) -``` - -- [ ] **Step 3: Add failing config and manifest tests** - -In both `test_manifest.py` files, assert default settings do not expose `agentIdMode`: - -```python -self.assertNotIn("agentIdMode", settings) -``` - -In both `test_config.py` files, assert loaded config does not include `agentIdMode` by default and `MEMIND_AGENT_ID_MODE` is no longer accepted as a documented override: - -```python -self.assertNotIn("agentIdMode", config) -``` - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_identity.py \ - memind-integrations/claude-code/tests/test_manifest.py \ - memind-integrations/claude-code/tests/test_config.py \ - memind-integrations/codex/tests/test_identity.py \ - memind-integrations/codex/tests/test_manifest.py \ - memind-integrations/codex/tests/test_config.py -``` - -Expected: tests fail because `agentIdMode` still exists and `resolve_identity()` can still return the base agent ID. - -- [ ] **Step 4: Update identity implementation** - -In both `identity.py` files, change `resolve_identity()` to: - -```python -def resolve_identity(config, hook_input): - cwd = hook_input.get("cwd") or os.getcwd() - user_id = config.get("userId") or f"local{SEPARATOR}{getpass.getuser()}" - base_agent = config.get("agentId") or "claude-code" # use "codex" in the Codex file - agent_id = f"{base_agent}{SEPARATOR}{project_slug(cwd)}" - return {"userId": user_id, "agentId": agent_id} -``` - -Keep `_git_remote()`, `_hash()`, and `project_slug()` unchanged unless tests reveal a real bug. - -- [ ] **Step 5: Remove configuration surface** - -In both `config.py` files: - -- Remove `"agentIdMode": "project"` from `DEFAULT_SETTINGS`. -- Remove `"MEMIND_AGENT_ID_MODE": ("agentIdMode", str)` from `ENV_MAP`. - -In both `settings.json` files: - -- Remove the `agentIdMode` property. - -- [ ] **Step 6: Run tests** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_identity.py \ - memind-integrations/claude-code/tests/test_manifest.py \ - memind-integrations/claude-code/tests/test_config.py \ - memind-integrations/codex/tests/test_identity.py \ - memind-integrations/codex/tests/test_manifest.py \ - memind-integrations/codex/tests/test_config.py -``` - -Expected: all selected tests pass. - -- [ ] **Step 7: Commit** - -```bash -git add \ - memind-integrations/claude-code/scripts/lib/identity.py \ - memind-integrations/claude-code/scripts/lib/config.py \ - memind-integrations/claude-code/settings.json \ - memind-integrations/claude-code/tests/test_identity.py \ - memind-integrations/claude-code/tests/test_manifest.py \ - memind-integrations/claude-code/tests/test_config.py \ - memind-integrations/codex/scripts/lib/identity.py \ - memind-integrations/codex/scripts/lib/config.py \ - memind-integrations/codex/settings.json \ - memind-integrations/codex/tests/test_identity.py \ - memind-integrations/codex/tests/test_manifest.py \ - memind-integrations/codex/tests/test_config.py -git commit -m "fix: make coding agent identities project scoped" -``` - -### Task 2: Normalize Session, Turn, Timeline, And Event Attribution - -**Files:** -- Modify: `memind-integrations/claude-code/scripts/lib/agent_timeline.py` -- Modify: `memind-integrations/claude-code/scripts/retrieve.py` -- Modify: `memind-integrations/claude-code/scripts/pre_tool_use.py` -- Modify: `memind-integrations/claude-code/scripts/post_tool_use.py` -- Modify: `memind-integrations/claude-code/scripts/ingest.py` -- Modify: `memind-integrations/claude-code/tests/test_agent_timeline.py` -- Modify: `memind-integrations/claude-code/tests/test_hooks.py` -- Modify: `memind-integrations/codex/scripts/lib/agent_timeline.py` -- Modify: `memind-integrations/codex/scripts/retrieve.py` -- Modify: `memind-integrations/codex/scripts/pre_tool_use.py` -- Modify: `memind-integrations/codex/scripts/post_tool_use.py` -- Modify: `memind-integrations/codex/scripts/ingest.py` -- Modify: `memind-integrations/codex/tests/test_agent_timeline.py` -- Modify: `memind-integrations/codex/tests/test_hooks.py` - -- [ ] **Step 1: Add failing tests for event metadata** - -In both integration `test_agent_timeline.py` files, extend existing user prompt/tool/stop tests to assert metadata contains: - -```python -self.assertEqual(event["metadata"]["sessionId"], "s") -self.assertEqual(event["metadata"]["sourceClient"], "claude-code") # "codex" in Codex tests -self.assertEqual(event["metadata"]["turnId"], "s-turn-1") -self.assertEqual(event["metadata"]["turnSeq"], 1) -``` - -For `build_timeline_payload()`, add assertions: - -```python -self.assertEqual(payload["metadata"]["sessionId"], "s") -self.assertEqual(payload["metadata"]["sourceClient"], "claude-code") # "codex" in Codex tests -self.assertEqual(payload["metadata"]["turnId"], "s-turn-1") -self.assertEqual(payload["metadata"]["turnSeq"], 1) -self.assertEqual(payload["metadata"]["eventIds"], ["e1", "e2"]) -self.assertEqual(payload["timelineId"], "s-turn-1-timeline") -``` - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_agent_timeline.py \ - memind-integrations/codex/tests/test_agent_timeline.py -``` - -Expected: tests fail because session/source metadata is not consistently present on every event and timeline metadata. - -- [ ] **Step 2: Centralize base event metadata** - -In both `agent_timeline.py` files, update `_base_event()` so all events created through it include: - -```python -metadata = { - "hookEventName": hook_input.get("hook_event_name"), - "sessionId": session_id, - "sourceClient": source_client, -} -``` - -Keep `turnId` and `turnSeq` only when passed. - -- [ ] **Step 3: Add session/source metadata to tool events** - -In both `normalize_hook_event()` implementations, initialize metadata with: - -```python -metadata = { - "hookEventName": hook_input.get("hook_event_name"), - "sessionId": session_id, - "sourceClient": source_client, -} -``` - -Then merge normalization metadata, turn metadata, and redaction metadata as today. - -- [ ] **Step 4: Add timeline metadata** - -In both `build_timeline_payload()` implementations, include: - -```python -"metadata": { - "userId": identity.get("userId"), - "agentId": identity.get("agentId"), - "sessionId": session_id, - "sourceClient": source_client, - "eventIds": [event["eventId"] for event in events if event.get("eventId")], -} -``` - -Preserve existing `turnId`, `turnSeq`, and `project` behavior. - -- [ ] **Step 5: Run tests** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_agent_timeline.py \ - memind-integrations/claude-code/tests/test_hooks.py \ - memind-integrations/codex/tests/test_agent_timeline.py \ - memind-integrations/codex/tests/test_hooks.py -``` - -Expected: all selected tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add \ - memind-integrations/claude-code/scripts/lib/agent_timeline.py \ - memind-integrations/claude-code/scripts/retrieve.py \ - memind-integrations/claude-code/scripts/pre_tool_use.py \ - memind-integrations/claude-code/scripts/post_tool_use.py \ - memind-integrations/claude-code/scripts/ingest.py \ - memind-integrations/claude-code/tests/test_agent_timeline.py \ - memind-integrations/claude-code/tests/test_hooks.py \ - memind-integrations/codex/scripts/lib/agent_timeline.py \ - memind-integrations/codex/scripts/retrieve.py \ - memind-integrations/codex/scripts/pre_tool_use.py \ - memind-integrations/codex/scripts/post_tool_use.py \ - memind-integrations/codex/scripts/ingest.py \ - memind-integrations/codex/tests/test_agent_timeline.py \ - memind-integrations/codex/tests/test_hooks.py -git commit -m "fix: normalize agent timeline attribution metadata" -``` - -### Task 3: Expand Normalized Agent Event Kinds - -**Files:** -- Modify: `memind-integrations/claude-code/scripts/lib/agent_timeline.py` -- Modify: `memind-integrations/claude-code/tests/test_agent_timeline.py` -- Modify: `memind-integrations/codex/scripts/lib/agent_timeline.py` -- Modify: `memind-integrations/codex/tests/test_agent_timeline.py` -- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEventKind.java` -- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java` - -- [ ] **Step 1: Add failing Python normalization tests** - -In Claude Code `test_agent_timeline.py`, add tests for: - -```python -def test_normalizes_notification_event(self): - event = normalize_notification_event( - { - "hook_event_name": "Notification", - "session_id": "s", - "message": "Claude needs permission to run Bash", - "timestamp": "2026-05-24T10:00:00Z", - }, - seq=1, - turn_id="s-turn-1", - turn_seq=1, - ) - self.assertEqual(event["kind"], "notification") - self.assertEqual(event["text"], "Claude needs permission to run Bash") - self.assertEqual(event["status"], "success") - - -def test_normalizes_subagent_stop_event(self): - event = normalize_subagent_stop_event( - { - "hook_event_name": "SubagentStop", - "session_id": "s", - "subagent_type": "explorer", - "message": "Found failing resolver test", - "timestamp": "2026-05-24T10:01:00Z", - }, - seq=2, - turn_id="s-turn-1", - turn_seq=1, - ) - self.assertEqual(event["kind"], "subagent_stop") - self.assertEqual(event["operation"], "explorer") - self.assertIn("Found failing resolver test", event["text"]) -``` - -In both Claude Code and Codex `test_agent_timeline.py`, add tests for: - -```python -def test_normalizes_compact_boundary_event(self): - event = normalize_compact_boundary_event( - {"hook_event_name": "PreCompact", "session_id": "s", "timestamp": "2026-05-24T10:02:00Z"}, - seq=3, - turn_id="s-turn-1", - turn_seq=1, - ) - self.assertEqual(event["kind"], "compact_boundary") - self.assertEqual(event["status"], "success") - - -def test_normalizes_session_end_event(self): - event = normalize_session_end_event( - {"hook_event_name": "SessionEnd", "session_id": "s", "timestamp": "2026-05-24T10:03:00Z"}, - seq=4, - turn_id="s-turn-1", - turn_seq=1, - ) - self.assertEqual(event["kind"], "session_end") - self.assertEqual(event["status"], "success") -``` - -Codex may not use compact/session-end hooks yet, but shared parsing should accept those event kinds if rawdata arrives from another adapter. - -- [ ] **Step 2: Implement Python normalization helpers** - -In Claude Code `agent_timeline.py`, add: - -```python -def normalize_notification_event(hook_input, seq, turn_id=None, turn_seq=None): - text, redaction_kinds = redact_text( - hook_input.get("message") - or hook_input.get("notification") - or hook_input.get("text") - or "" - ) - event = _base_event(hook_input, seq, "notification", turn_id, turn_seq, text) - event["text"] = text - event["status"] = "success" - metadata = dict(event["metadata"]) - if _looks_blocking_notification(text): - metadata["notificationKind"] = "blocked" - metadata["failureSignal"] = text - else: - metadata["notificationKind"] = "info" - if redaction_kinds: - metadata["redacted"] = True - metadata["redactionKinds"] = sorted(set(redaction_kinds)) - event["metadata"] = metadata - return {key: value for key, value in event.items() if value is not None and value != ""} -``` - -Add the small helper: - -```python -def _looks_blocking_notification(text): - lowered = (text or "").lower() - return any(token in lowered for token in ["permission", "blocked", "denied", "failed", "error"]) -``` - -Add: - -```python -def normalize_subagent_stop_event(hook_input, seq, turn_id=None, turn_seq=None): - subagent_type = hook_input.get("subagent_type") or hook_input.get("subagentType") or hook_input.get("type") - text, redaction_kinds = redact_text( - hook_input.get("message") - or hook_input.get("summary") - or hook_input.get("result") - or "" - ) - event = _base_event(hook_input, seq, "subagent_stop", turn_id, turn_seq, text) - event["text"] = text - event["operation"] = subagent_type - event["status"] = "success" - metadata = dict(event["metadata"]) - if subagent_type: - metadata["subagentType"] = subagent_type - if redaction_kinds: - metadata["redacted"] = True - metadata["redactionKinds"] = sorted(set(redaction_kinds)) - event["metadata"] = metadata - return {key: value for key, value in event.items() if value is not None and value != ""} -``` - -Add: - -```python -def normalize_compact_boundary_event(hook_input, seq, turn_id=None, turn_seq=None): - event = _base_event(hook_input, seq, "compact_boundary", turn_id, turn_seq, "compact") - event["status"] = "success" - event["operation"] = hook_input.get("trigger") or hook_input.get("compact_reason") or "compact" - return {key: value for key, value in event.items() if value is not None and value != ""} - - -def normalize_session_end_event(hook_input, seq, turn_id=None, turn_seq=None): - event = _base_event(hook_input, seq, "session_end", turn_id, turn_seq, "session_end") - event["status"] = "success" - event["operation"] = hook_input.get("reason") or hook_input.get("session_end_reason") or "session_end" - return {key: value for key, value in event.items() if value is not None and value != ""} -``` - -In Codex `agent_timeline.py`, add only the helpers that are exercised by Codex tests in this phase. Do not add Codex hook scripts, manifest entries, or README claims for `Notification`, `SubagentStop`, `PreCompact`, or `SessionEnd` unless Codex support is explicitly added and tested in the Codex adapter. If compact/session-end helper functions are added for parser compatibility, keep them private to normalization tests and document that they are accepted raw event shapes, not registered Codex hooks. - -- [ ] **Step 3: Add failing Java enum parsing test** - -In `AgentTimelineContentTest.java`, add a test that parses/uses these wire values: - -```java -assertThat(AgentEventKind.fromWireValue("notification")).isEqualTo(AgentEventKind.NOTIFICATION); -assertThat(AgentEventKind.fromWireValue("subagent_stop")).isEqualTo(AgentEventKind.SUBAGENT_STOP); -assertThat(AgentEventKind.fromWireValue("compact_boundary")).isEqualTo(AgentEventKind.COMPACT_BOUNDARY); -assertThat(AgentEventKind.fromWireValue("synthetic_boundary")).isEqualTo(AgentEventKind.SYNTHETIC_BOUNDARY); -``` - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent test \ - -Dtest=AgentTimelineContentTest -``` - -Expected: test fails because enum constants are missing. - -- [ ] **Step 4: Add Java event kinds** - -In `AgentEventKind.java`, add: - -```java -TOOL_START, -TOOL_FAILURE, -SUBAGENT_START, -SUBAGENT_STOP, -NOTIFICATION, -COMPACT_BOUNDARY, -SYNTHETIC_BOUNDARY, -``` - -Keep existing constants. `TASK_COMPLETED` remains a general rawdata-agent kind even if Claude Code/Codex do not register a `TaskCompleted` hook in this phase. - -- [ ] **Step 5: Run selected tests** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_agent_timeline.py \ - memind-integrations/codex/tests/test_agent_timeline.py -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent test \ - -Dtest=AgentTimelineContentTest -``` - -Expected: all selected tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add \ - memind-integrations/claude-code/scripts/lib/agent_timeline.py \ - memind-integrations/claude-code/tests/test_agent_timeline.py \ - memind-integrations/codex/scripts/lib/agent_timeline.py \ - memind-integrations/codex/tests/test_agent_timeline.py \ - memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEventKind.java \ - memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java -git commit -m "feat: expand normalized agent event kinds" -``` - -### Task 4: Add Claude Code Notification And Subagent Hook Capture - -**Files:** -- Create: `memind-integrations/claude-code/scripts/notification.py` -- Create: `memind-integrations/claude-code/scripts/subagent_stop.py` -- Modify: `memind-integrations/claude-code/hooks/hooks.json` -- Modify: `memind-integrations/claude-code/tests/test_hooks.py` -- Modify: `memind-integrations/claude-code/tests/test_manifest.py` - -- [ ] **Step 1: Add failing manifest tests** - -In `memind-integrations/claude-code/tests/test_manifest.py`, add `Notification` and `SubagentStop` to the expected hook list: - -```python -for event in [ - "SessionStart", - "UserPromptSubmit", - "PreToolUse", - "PostToolUse", - "Notification", - "SubagentStop", - "PreCompact", - "Stop", - "SessionEnd", -]: - self.assertIn(event, hooks) -``` - -Assert both new hook commands are async and fail-open sized: - -```python -self.assertTrue(hooks["Notification"][0]["hooks"][0]["async"]) -self.assertTrue(hooks["SubagentStop"][0]["hooks"][0]["async"]) -self.assertEqual(hooks["Notification"][0]["hooks"][0]["timeout"], 5) -self.assertEqual(hooks["SubagentStop"][0]["hooks"][0]["timeout"], 5) -``` - -- [ ] **Step 2: Add failing hook execution tests** - -In `memind-integrations/claude-code/tests/test_hooks.py`, add: - -```python -def test_notification_buffers_event(self): - with tempfile.TemporaryDirectory() as tmp: - state_dir = Path(tmp) / "state" - env = { - "CLAUDE_PLUGIN_ROOT": str(ROOT), - "PYTHONPATH": str(ROOT), - "MEMIND_CLAUDE_STATE_ROOT": str(state_dir), - } - output = self.run_hook( - "notification.py", - { - "hook_event_name": "Notification", - "cwd": tmp, - "session_id": "s1", - "message": "Permission required for Bash", - }, - env=env, - ) - self.assertEqual(output, {"continue": True}) - event = json.loads(next(state_dir.glob("*.json")).read_text())["agentEvents"][0] - self.assertEqual(event["kind"], "notification") - self.assertEqual(event["metadata"]["notificationKind"], "blocked") - - -def test_subagent_stop_buffers_event(self): - with tempfile.TemporaryDirectory() as tmp: - state_dir = Path(tmp) / "state" - env = { - "CLAUDE_PLUGIN_ROOT": str(ROOT), - "PYTHONPATH": str(ROOT), - "MEMIND_CLAUDE_STATE_ROOT": str(state_dir), - } - output = self.run_hook( - "subagent_stop.py", - { - "hook_event_name": "SubagentStop", - "cwd": tmp, - "session_id": "s1", - "subagent_type": "explorer", - "message": "Found failing resolver test", - }, - env=env, - ) - self.assertEqual(output, {"continue": True}) - event = json.loads(next(state_dir.glob("*.json")).read_text())["agentEvents"][0] - self.assertEqual(event["kind"], "subagent_stop") - self.assertEqual(event["operation"], "explorer") -``` - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_manifest.py \ - memind-integrations/claude-code/tests/test_hooks.py -``` - -Expected: tests fail because scripts and hook entries do not exist. - -- [ ] **Step 3: Implement `notification.py`** - -Create `memind-integrations/claude-code/scripts/notification.py` using the same fail-open pattern as `pre_tool_use.py`: - -```python -#!/usr/bin/env python3 -import json -import os -import sys - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from ingest import state_root -from lib.agent_timeline import normalize_notification_event -from lib.config import load_config -from lib.logging_utils import debug_log -from lib.state import SessionStateStore - - -def main(): - try: - hook_input = json.loads(sys.stdin.read() or "{}") - config = load_config() - session_id = hook_input.get("session_id") or "unknown-session" - hook_input["source_client"] = config.get("sourceClient") or "claude-code" - with SessionStateStore(state_root()).locked(session_id) as state: - turn_id, turn_seq = state.ensure_agent_turn(session_id) - seq = state.next_agent_seq() - state.append_agent_event( - normalize_notification_event( - hook_input, seq, turn_id=turn_id, turn_seq=turn_seq - ) - ) - except Exception as exc: - try: - debug_log(load_config(), "notification_failed", {"error": str(exc)}) - except Exception: - pass - print(json.dumps({"continue": True})) - - -if __name__ == "__main__": - main() -``` - -Add the standard Apache license header before imports to match repository style. - -- [ ] **Step 4: Implement `subagent_stop.py`** - -Create `memind-integrations/claude-code/scripts/subagent_stop.py` with the same structure, calling `normalize_subagent_stop_event()` and logging `"subagent_stop_failed"`. - -- [ ] **Step 5: Register hooks** - -In `memind-integrations/claude-code/hooks/hooks.json`, add: - -```json -"Notification": [ - { - "hooks": [ - { - "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/notification.py\"", - "timeout": 5, - "async": true - } - ] - } -], -"SubagentStop": [ - { - "hooks": [ - { - "type": "command", - "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/subagent_stop.py\"", - "timeout": 5, - "async": true - } - ] - } -] -``` - -- [ ] **Step 6: Run tests** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_manifest.py \ - memind-integrations/claude-code/tests/test_hooks.py -``` - -Expected: all selected tests pass. - -- [ ] **Step 7: Commit** - -```bash -git add \ - memind-integrations/claude-code/scripts/notification.py \ - memind-integrations/claude-code/scripts/subagent_stop.py \ - memind-integrations/claude-code/hooks/hooks.json \ - memind-integrations/claude-code/tests/test_hooks.py \ - memind-integrations/claude-code/tests/test_manifest.py -git commit -m "feat: capture claude code notification and subagent hooks" -``` - -### Task 5: Preserve Codex Hook Boundaries Without Simulating Unsupported Hooks - -**Files:** -- Modify: `memind-integrations/codex/hooks/hooks.json` -- Modify: `memind-integrations/codex/scripts/install_codex_hooks.py` -- Modify: `memind-integrations/codex/tests/test_manifest.py` -- Modify: `memind-integrations/codex/tests/test_installer.py` -- Modify: `memind-integrations/codex/README.md` - -- [ ] **Step 1: Add explicit Codex supported-hook test** - -In `memind-integrations/codex/tests/test_manifest.py`, keep the supported set strict: - -```python -self.assertEqual( - set(hooks), - {"SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"}, -) -self.assertNotIn("PreCompact", hooks) -self.assertNotIn("SessionEnd", hooks) -self.assertNotIn("Notification", hooks) -self.assertNotIn("SubagentStop", hooks) -``` - -Run: - -```bash -python3 -m unittest memind-integrations/codex/tests/test_manifest.py -``` - -Expected: pass against current manifest. This test protects against accidental Claude Code hook leakage. - -- [ ] **Step 2: Add installer regression assertion** - -In `memind-integrations/codex/tests/test_installer.py`, add assertions after install that the installed hooks contain exactly the supported set plus any unrelated pre-existing hooks: - -```python -memind_events = { - event - for event, groups in hooks["hooks"].items() - for group in groups - for hook in group.get("hooks", []) - if "memind-integrations/codex" in hook.get("command", "") or "/memind/codex/" in hook.get("command", "") -} -self.assertEqual( - memind_events, - {"SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"}, -) -``` - -- [ ] **Step 3: Update Codex README** - -In `memind-integrations/codex/README.md`, add a short note under Hook Events: - -```markdown -Codex currently registers only the hook events listed above. Memind does not simulate Claude Code-only lifecycle -events such as `PreCompact`, `SessionEnd`, `Notification`, or `SubagentStop` in the Codex adapter. If Codex adds -native support for additional lifecycle events, they should be added as explicit hooks with tests. -``` - -- [ ] **Step 4: Run Codex tests** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/codex/tests/test_manifest.py \ - memind-integrations/codex/tests/test_installer.py -``` - -Expected: all selected tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add \ - memind-integrations/codex/hooks/hooks.json \ - memind-integrations/codex/scripts/install_codex_hooks.py \ - memind-integrations/codex/tests/test_manifest.py \ - memind-integrations/codex/tests/test_installer.py \ - memind-integrations/codex/README.md -git commit -m "test: keep codex hook support explicit" -``` - -### Task 6: Make Durable Journal Flush Boundaries Explicit - -**Files:** -- Modify: `memind-integrations/claude-code/scripts/lib/state.py` -- Modify: `memind-integrations/claude-code/scripts/ingest.py` -- Modify: `memind-integrations/claude-code/scripts/pre_compact.py` -- Modify: `memind-integrations/claude-code/scripts/session_end.py` -- Modify: `memind-integrations/claude-code/tests/test_state.py` -- Modify: `memind-integrations/claude-code/tests/test_hooks.py` -- Modify: `memind-integrations/codex/scripts/lib/state.py` -- Modify: `memind-integrations/codex/scripts/ingest.py` -- Modify: `memind-integrations/codex/tests/test_state.py` -- Modify: `memind-integrations/codex/tests/test_hooks.py` - -- [ ] **Step 1: Add state cleanup tests** - -In both `test_state.py` files, add tests for empty state cleanup behavior through a new `is_empty()` helper: - -```python -def test_state_reports_empty_after_all_events_are_cleared_and_turn_closed(self): - with tempfile.TemporaryDirectory() as tmp: - store = SessionStateStore(Path(tmp)) - with store.locked("session-1") as state: - turn_id, _turn_seq = state.start_agent_turn("session-1") - state.append_agent_event({"eventId": "e1", "seq": 1}) - state.clear_agent_events(["e1"]) - state.close_agent_turn(turn_id) - self.assertTrue(state.is_empty()) -``` - -For Codex, use the same test shape but name the local variable `session_key = "session-1"` before calling `store.locked(session_key)`. The expected behavior is identical because Codex's state store already converts hook payloads to a stable session key before opening the store. - -- [ ] **Step 2: Add boundary preservation test** - -In both `test_state.py` files, update or add a soft cap test so important boundaries survive truncation: - -```python -def test_agent_event_buffer_soft_cap_preserves_current_turn_boundaries(self): - with tempfile.TemporaryDirectory() as tmp: - store = SessionStateStore(Path(tmp)) - with store.locked("session-1") as state: - state.append_agent_event({"eventId": "prompt", "seq": 1, "kind": "user_prompt"}) - for index in range(600): - state.append_agent_event({"eventId": f"e{index}", "seq": index + 2, "kind": "tool_result"}) - state.append_agent_event({"eventId": "stop", "seq": 700, "kind": "stop"}) - with store.locked("session-1") as state: - events = state.agent_events() - self.assertLessEqual(len(events), 500) - self.assertEqual(events[-1]["eventId"], "stop") - self.assertTrue(state.data["agentEventsTruncated"]) - self.assertIn("agentEventsDropped", state.data) -``` - -This test does not require preserving the oldest `USER_PROMPT` forever if the session has exceeded the cap before flush. It does require explicit truncation metadata and preserving the newest terminal boundary. - -- [ ] **Step 3: Implement state helpers** - -In both `state.py` files, add: - -```python -def is_empty(self): - return ( - not self.data.get("agentEvents") - and not self.data.get("currentAgentTurnId") - and not self.data.get("currentAgentTurnSeq") - ) -``` - -When soft cap truncates, increment a counter: - -```python -dropped = len(events) - MAX_AGENT_EVENTS -events = events[-MAX_AGENT_EVENTS:] -self.data["agentEventsTruncated"] = True -self.data["agentEventsDropped"] = int(self.data.get("agentEventsDropped", 0)) + dropped -``` - -- [ ] **Step 4: Add explicit Claude Code boundary event tests** - -In Claude Code `test_hooks.py`, add: - -```python -def test_pre_compact_appends_compact_boundary_before_flush(self): - sys.path.insert(0, str(ROOT / "scripts")) - import ingest - from scripts.lib.state import SessionStateStore - - config = { - "memindApiUrl": "http://127.0.0.1:8366", - "memindApiToken": None, - "autoIngestAgentTimeline": True, - "ingestRetrySpool": False, - "sourceClient": "claude-code", - "agentId": "claude-code", - "userId": "u", - } - with tempfile.TemporaryDirectory() as tmp: - state_root = Path(tmp) / "state" - with SessionStateStore(state_root).locked("s1") as state: - state.append_agent_event( - { - "eventId": "e1", - "seq": 1, - "kind": "user_prompt", - "text": "Continue before compaction", - "metadata": {"turnId": "s1-turn-1", "turnSeq": 1}, - } - ) - with mock.patch.object(ingest, "state_root", return_value=state_root): - with mock.patch.object(ingest, "retry_root", return_value=Path(tmp) / "retry"): - with mock.patch.object(ingest, "MemindClient") as client_cls: - client = client_cls.return_value - client.extract = mock.AsyncMock(return_value=types.SimpleNamespace(status="SUCCESS")) - result = ingest.ingest_messages( - config, - { - "hook_event_name": "PreCompact", - "session_id": "s1", - "cwd": tmp, - "timestamp": "2026-05-24T10:04:00Z", - }, - ) - self.assertEqual(result["agentEventsSubmitted"], 2) - raw_content = client.extract.await_args.args[2] - self.assertEqual(raw_content["events"][-1]["kind"], "compact_boundary") -``` - -And: - -```python -def test_session_end_appends_session_end_before_flush(self): - sys.path.insert(0, str(ROOT / "scripts")) - import ingest - from scripts.lib.state import SessionStateStore - - config = { - "memindApiUrl": "http://127.0.0.1:8366", - "memindApiToken": None, - "autoIngestAgentTimeline": True, - "ingestRetrySpool": False, - "sourceClient": "claude-code", - "agentId": "claude-code", - "userId": "u", - } - with tempfile.TemporaryDirectory() as tmp: - state_root = Path(tmp) / "state" - with SessionStateStore(state_root).locked("s1") as state: - state.append_agent_event( - { - "eventId": "e1", - "seq": 1, - "kind": "command", - "command": "npm test payment", - "metadata": {"turnId": "s1-turn-1", "turnSeq": 1}, - } - ) - with mock.patch.object(ingest, "state_root", return_value=state_root): - with mock.patch.object(ingest, "retry_root", return_value=Path(tmp) / "retry"): - with mock.patch.object(ingest, "MemindClient") as client_cls: - client = client_cls.return_value - client.extract = mock.AsyncMock(return_value=types.SimpleNamespace(status="SUCCESS")) - result = ingest.ingest_messages( - config, - { - "hook_event_name": "SessionEnd", - "session_id": "s1", - "cwd": tmp, - "timestamp": "2026-05-24T10:05:00Z", - }, - ) - self.assertEqual(result["agentEventsSubmitted"], 2) - raw_content = client.extract.await_args.args[2] - self.assertEqual(raw_content["events"][-1]["kind"], "session_end") -``` - -These tests intentionally call `ingest.ingest_messages()` directly instead of shelling out to `pre_compact.py` and `session_end.py`, because the behavior under test is the shared flush path and boundary append logic. - -- [ ] **Step 5: Refactor Claude Code flush entrypoint** - -In `ingest.py`, add a helper: - -```python -def _append_boundary_event(state, session_id, hook_input): - hook_name = hook_input.get("hook_event_name") or "" - if hook_name == "Stop": - return _append_stop_events(state, session_id, hook_input) - if hook_name == "PreCompact": - turn_id, turn_seq = state.ensure_agent_turn(session_id) - seq = state.next_agent_seq() - state.append_agent_event( - normalize_compact_boundary_event(hook_input, seq, turn_id=turn_id, turn_seq=turn_seq) - ) - return turn_id - if hook_name == "SessionEnd": - turn_id, turn_seq = state.ensure_agent_turn(session_id) - seq = state.next_agent_seq() - state.append_agent_event( - normalize_session_end_event(hook_input, seq, turn_id=turn_id, turn_seq=turn_seq) - ) - return turn_id - return None -``` - -Use this helper where `_append_stop_events()` is called today. - -After successful flush and `state.close_agent_turn(submitted_turn_id)`, keep the empty state file behavior conservative: clearing events is enough for this phase. Do not add `SessionStateStore.delete_if_empty(...)` in this phase. The new `is_empty()` helper is a testable state invariant and a future cleanup hook, not a requirement to delete journal files now. Never delete a state file that still contains unflushed events. - -- [ ] **Step 6: Keep Codex Stop boundary behavior** - -In Codex `ingest.py`, preserve the existing Stop-only behavior. Add assertions to existing Codex Stop tests that the final event is `stop` and the turn closes after successful flush. - -- [ ] **Step 7: Run tests** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_state.py \ - memind-integrations/claude-code/tests/test_hooks.py \ - memind-integrations/codex/tests/test_state.py \ - memind-integrations/codex/tests/test_hooks.py -``` - -Expected: all selected tests pass. - -- [ ] **Step 8: Commit** - -```bash -git add \ - memind-integrations/claude-code/scripts/lib/state.py \ - memind-integrations/claude-code/scripts/ingest.py \ - memind-integrations/claude-code/scripts/pre_compact.py \ - memind-integrations/claude-code/scripts/session_end.py \ - memind-integrations/claude-code/tests/test_state.py \ - memind-integrations/claude-code/tests/test_hooks.py \ - memind-integrations/codex/scripts/lib/state.py \ - memind-integrations/codex/scripts/ingest.py \ - memind-integrations/codex/tests/test_state.py \ - memind-integrations/codex/tests/test_hooks.py -git commit -m "fix: make agent journal flush boundaries explicit" -``` - -### Task 7: Update rawdata-agent Episode Assembly For Richer Events - -**Files:** -- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java` -- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java` -- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java` -- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java` - -- [ ] **Step 1: Add failing episode boundary test** - -In `AgentEpisodeAssemblerTest.java`, add a test with: - -- `USER_PROMPT` seq 1. -- `FILE_READ` seq 2. -- `SUBAGENT_STOP` seq 3. -- `STOP` seq 4. -- `USER_PROMPT` seq 5. -- `COMMAND` seq 6. -- `COMPACT_BOUNDARY` seq 7. - -Assert: - -```java -assertThat(episodes).hasSize(2); -assertThat(episodes.get(0).eventIds()).containsExactly("e1", "e2", "e3", "e4"); -assertThat(episodes.get(1).eventIds()).containsExactly("e5", "e6", "e7"); -assertThat(episodes.get(0).phase()).isEqualTo("full"); -assertThat(episodes.get(1).phase()).isEqualTo("full"); -``` - -- [ ] **Step 2: Add failing phase classification test** - -In `AgentEpisodeAssemblerTest.java`, add assertions that: - -- `SUBAGENT_STOP` is not an implementation event by itself. -- `NOTIFICATION` with failed/cancelled status contributes to failure signals. -- `COMPACT_BOUNDARY`, `SESSION_END`, `SYNTHETIC_BOUNDARY`, and `STOP` are terminal events. - -- [ ] **Step 3: Implement terminal boundaries** - -In `AgentEpisodeAssembler.isTerminal()`, include: - -```java -return event.kind() == AgentEventKind.STOP - || event.kind() == AgentEventKind.SESSION_END - || event.kind() == AgentEventKind.COMPACT_BOUNDARY - || event.kind() == AgentEventKind.SYNTHETIC_BOUNDARY - || event.kind() == AgentEventKind.TASK_COMPLETED; -``` - -- [ ] **Step 4: Update phase classification** - -In `phase(AgentEvent event)`, treat: - -- `FILE_EDIT` as `implementation`. -- successful `COMMAND` and `TEST_RESULT` as `validation`. -- terminal events and `ASSISTANT_MESSAGE` as `handoff`. -- `SUBAGENT_STOP`, `NOTIFICATION`, `FILE_READ`, `TOOL_RESULT`, and unknown tool evidence as `investigation` unless stronger local evidence indicates otherwise. - -- [ ] **Step 5: Ensure formatter does not drop new events** - -In `AgentTimelineContent` formatting tests, add a sample event for `notification`, `subagent_stop`, and `compact_boundary`. Assert formatted text contains the kind and meaningful text/operation. - -- [ ] **Step 6: Run tests** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent test \ - -Dtest=AgentEpisodeAssemblerTest,AgentTimelineContentTest -``` - -Expected: selected tests pass. - -- [ ] **Step 7: Commit** - -```bash -git add \ - memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssembler.java \ - memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java \ - memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentEpisodeAssemblerTest.java \ - memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java -git commit -m "feat: segment agent episodes across lifecycle boundaries" -``` - -### Task 8: Improve Deterministic Tool And Resolution Evidence - -**Files:** -- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java` -- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java` -- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java` -- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyLlmTest.java` - -- [ ] **Step 1: Add failing deterministic resolution test** - -In `AgentItemExtractionStrategyTest.java`, add a segment metadata fixture with: - -- `failureSignals = ["payment rounding mismatch"]` -- `commandEvents` containing failed `npm test payment` at seq 3 and successful `npm test payment` at seq 8. -- `fileEvents` for `src/payment/calc.ts` between seq 3 and seq 8. -- `eventIds` containing all evidence IDs. - -Assert extracted deterministic `resolution` metadata: - -```java -assertThat(resolution.metadata().get("validatedBy")).isEqualTo("npm test payment"); -assertThat((List) resolution.metadata().get("evidenceEventIds")) - .containsExactly("failed-test", "edit-calc", "passed-test"); -``` - -This test makes the "later successful validation" rule precise: later means `candidate.seq > failed.seq`, successful means `status == success`, and matching means `sameCommandFamily(failed.command, candidate.command)`. - -- [ ] **Step 2: Add weak notification test** - -In `AgentItemExtractionStrategyTest.java`, add a segment metadata fixture with: - -```java -Map.of( - "segmentType", "agent_episode", - "episodeId", "episode-notification", - "eventIds", List.of("notice-1"), - "files", List.of(), - "commands", List.of(), - "toolNames", List.of(), - "failureSignals", List.of(), - "outcome", "unknown") -``` - -The segment text should mention a harmless notification, such as `Claude is waiting for input.`. Assert: - -```java -assertThat(strategy.extract(List.of(segment), List.of(), config).block()).isEmpty(); -``` - -If the test uses `AgentMemoryItemFactory` directly, assert `deterministicEntries(segment)` is empty. This prevents low-value notifications from becoming memory items by themselves. - -- [ ] **Step 3: Add subagent evidence test** - -In `AgentItemExtractionStrategyLlmTest.java`, add a segment whose metadata includes: - -```java -Map.of( - "segmentType", "agent_episode", - "episodeId", "episode-subagent", - "eventIds", List.of("prompt-1", "subagent-1", "stop-1"), - "files", List.of("src/payment/calc.ts"), - "commands", List.of("npm test payment"), - "toolNames", List.of("Task"), - "failureSignals", List.of(), - "outcome", "success") -``` - -Mock the structured chat client to return an extracted item: - -```java -new MemoryItemExtractionResponse.ExtractedItem( - "When payment test failures are unclear, ask an explorer subagent to inspect the failing resolver before editing calc.ts.", - 0.86f, - null, - null, - List.of("playbooks"), - Map.of("evidenceEventIds", List.of("subagent-1")), - "playbook") -``` - -Assert the item is accepted and its metadata keeps `evidenceEventIds = ["subagent-1"]`. Add a companion response with `evidenceEventIds = ["outside-event"]` and assert it is rejected. - -- [ ] **Step 4: Tighten deterministic resolution logic** - -In `AgentMemoryItemFactory`, keep the existing validation scan but ensure: - -- failed command event must have `failed() == true`; -- validation candidate must have `candidate.seq() > failed.seq()`; -- validation candidate must have `success() == true`; -- `sameCommandFamily(failed.command(), candidate.command())` must be true; -- file evidence is included only when `failed.seq < file.seq < candidate.seq`; -- all evidence IDs are non-blank and de-duplicated in event order. - -- [ ] **Step 5: Validate LLM evidence IDs** - -In `AgentItemExtractionStrategy.isValidItem()`, preserve the existing evidence validation and add a targeted test that rejects an LLM item with: - -```json -{"metadata": {"evidenceEventIds": ["outside-event"]}} -``` - -when `outside-event` is not in the segment metadata `eventIds`. - -- [ ] **Step 6: Run tests** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent test \ - -Dtest=AgentItemExtractionStrategyTest,AgentItemExtractionStrategyLlmTest -``` - -Expected: selected tests pass. - -- [ ] **Step 7: Commit** - -```bash -git add \ - memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java \ - memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategy.java \ - memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java \ - memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyLlmTest.java -git commit -m "fix: strengthen agent item evidence validation" -``` - -### Task 9: Update Captions For Lifecycle-Aware Episodes - -**Files:** -- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGenerator.java` -- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGeneratorTest.java` - -- [ ] **Step 1: Add failing caption tests** - -In `AgentCaptionGeneratorTest.java`, add tests for: - -- An episode with `goal = "Fix payment tests"`, `SUBAGENT_STOP`, file edit, failed test, passed test, and stop. -- An episode ending with `COMPACT_BOUNDARY`. - -Assert captions include: - -```java -assertThat(caption.text()).contains("Fix payment tests"); -assertThat(caption.text()).contains("src/payment/calc.ts"); -assertThat(caption.text()).contains("npm test payment"); -``` - -For compact boundary: - -```java -assertThat(caption.text()).contains("compact"); -``` - -- [ ] **Step 2: Update caption logic** - -Keep the caption deterministic and concise: - -- Prefer goal/user prompt. -- Include top files and commands. -- Include outcome. -- Mention compact/session boundary only when it is the terminal reason and useful for later continuation. -- Do not include raw tool output unless it is already present as a concise failure signal. - -- [ ] **Step 3: Run tests** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent test \ - -Dtest=AgentCaptionGeneratorTest -``` - -Expected: selected tests pass. - -- [ ] **Step 4: Commit** - -```bash -git add \ - memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGenerator.java \ - memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/caption/AgentCaptionGeneratorTest.java -git commit -m "feat: improve lifecycle-aware agent captions" -``` - -### Task 10: Update Documentation For Both Integrations - -**Files:** -- Modify: `memind-integrations/claude-code/README.md` -- Modify: `memind-integrations/codex/README.md` -- Modify: `docs/superpowers/specs/2026-05-24-rawdata-agent-design.md` only if the implementation has made the existing spec materially stale. - -- [ ] **Step 1: Update Claude Code README** - -Document: - -- Timeline-only ingestion. -- Project-scoped `agentId` is always enabled. -- `agentIdMode` no longer exists. -- Session/turn/timeline IDs are metadata, not Memind core project/session entities. -- Supported hooks: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Notification`, `SubagentStop`, `PreCompact`, `Stop`, `SessionEnd`. -- Failed extraction is spooled and retried on `SessionStart`. -- Retrieval Context Compiler is not part of this phase. - -- [ ] **Step 2: Update Codex README** - -Document: - -- Timeline-only ingestion. -- Project-scoped `agentId` is always enabled. -- `agentIdMode` no longer exists. -- Supported hooks: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`. -- Codex does not simulate Claude Code-only lifecycle hooks. -- Failed extraction is spooled and retried on `SessionStart`. -- Retrieval Context Compiler is not part of this phase. - -- [ ] **Step 3: Remove stale config examples** - -In both READMEs, remove examples like: - -```json -"agentIdMode": "project" -``` - -and remove environment examples: - -```bash -export MEMIND_AGENT_ID_MODE=project -``` - -- [ ] **Step 4: Run doc consistency search** - -Run: - -```bash -rg -n "agentIdMode|MEMIND_AGENT_ID_MODE|conversation rawdata|TaskCompleted" \ - memind-integrations/claude-code \ - memind-integrations/codex \ - docs/superpowers/specs/2026-05-24-rawdata-agent-design.md -``` - -Expected: - -- No `agentIdMode` or `MEMIND_AGENT_ID_MODE` remains in Claude Code/Codex integration docs or settings. -- Any `TaskCompleted` reference is either in the rawdata-agent general schema or removed from Claude Code/Codex hook documentation. Do not remove `TASK_COMPLETED` from the Java rawdata-agent model solely because Claude Code and Codex do not register a `TaskCompleted` hook in this phase. -- Conversation rawdata is not described as default Claude Code/Codex ingestion. - -- [ ] **Step 5: Commit** - -```bash -git add \ - memind-integrations/claude-code/README.md \ - memind-integrations/codex/README.md \ - docs/superpowers/specs/2026-05-24-rawdata-agent-design.md -git commit -m "docs: clarify coding agent timeline ingestion" -``` - -If the spec file does not need changes, omit it from `git add`. - -### Task 11: Full Verification - -**Files:** -- No planned source edits. - -- [ ] **Step 1: Run Claude Code integration tests** - -```bash -python3 -m unittest discover -s memind-integrations/claude-code/tests -p "test_*.py" -``` - -Expected: all tests pass. - -- [ ] **Step 2: Run Codex integration tests** - -```bash -python3 -m unittest discover -s memind-integrations/codex/tests -p "test_*.py" -``` - -Expected: all tests pass. - -- [ ] **Step 3: Run rawdata-agent plugin tests** - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent test -``` - -Expected: all tests pass. - -- [ ] **Step 4: Run broader affected Maven tests** - -If the rawdata-agent plugin touched shared core APIs, run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -am test -``` - -Expected: all tests pass. - -- [ ] **Step 5: Search for removed config and unsupported hook drift** - -```bash -rg -n "agentIdMode|MEMIND_AGENT_ID_MODE" memind-integrations/claude-code memind-integrations/codex -``` - -Expected: no output. - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_manifest.py \ - memind-integrations/codex/tests/test_manifest.py -``` - -Expected: manifests match the supported hook sets. - -- [ ] **Step 6: Inspect final diff** - -```bash -git status --short -git diff --stat -git diff --check -``` - -Expected: - -- Only planned files changed. -- No whitespace errors from `git diff --check`. - -- [ ] **Step 7: Final commit if verification required fixes** - -If verification required small fixes: - -```bash -git add docs/superpowers/plans/2026-05-26-agent-hook-journal-pipeline.md -git commit -m "test: verify agent hook journal pipeline" -``` - -Replace the `git add` path with the actual source or test files fixed during verification. If no fixes were needed, do not create an empty commit. - -## Acceptance Criteria - -- Claude Code and Codex no longer expose `agentIdMode`; both always use project-scoped `agentId`. -- Claude Code captures `Notification` and `SubagentStop` as durable timeline events. -- Codex remains explicit about its supported hook set and does not simulate unsupported Claude Code lifecycle events. -- `USER_PROMPT`, tools, assistant message, stop, compact, session end, notification, and subagent evidence can be represented as normalized `agent_event` records. -- Local journal truncation is explicit and does not silently hide data loss. -- Stop/compact/session boundaries flush `agent_timeline` rawdata without reintroducing transcript conversation ingestion. -- `rawdata-agent` can parse, format, segment, caption, and extract from richer event kinds. -- Deterministic resolution extraction uses precise later-success validation semantics. -- LLM-extracted agent memories cannot cite evidence event IDs outside the episode. -- All changed behavior is covered by Python and Java tests. - -## Notes For Implementation - -- Keep the adapter layer boring and deterministic. It should clean and normalize evidence, not decide high-level memory meaning. -- Do not add project/session tables or core concepts to Memind. -- Do not add a pre-extraction LLM gate in this phase. -- Do not remove `rawdata-toolcall`; it remains a separate plugin with a different scope. -- Keep generated hook scripts fail-open. Memory capture must not block the user's coding session if Memind is unavailable. -- Prefer small commits after each task so review can isolate identity, hook capture, journal behavior, rawdata-agent parsing, and docs. diff --git a/docs/superpowers/plans/2026-05-27-agent-context-compiler.md b/docs/superpowers/plans/2026-05-27-agent-context-compiler.md deleted file mode 100644 index 1b90336d..00000000 --- a/docs/superpowers/plans/2026-05-27-agent-context-compiler.md +++ /dev/null @@ -1,1406 +0,0 @@ -# Agent Context Compiler Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build a deterministic Agent Context Compiler for Claude Code and Codex so Memind memory is injected as high-value coding-agent context instead of lightly formatted query output. - -**Architecture:** Keep Memind core, OpenAPI, and `rawdata-agent` unchanged. Implement the compiler in the Claude Code and Codex integration layers: normalize retrieved rawdata/items/insights into common entries, classify them into agent-oriented sections, rank and deduplicate them, then render bounded context for `SessionStart` and `UserPromptSubmit`. `rawdata-agent` remains responsible for ingestion and extraction; integrations remain responsible for prompt injection. - -**Tech Stack:** Python 3.10+ hook scripts and `unittest`, Memind Python client query/retrieve APIs, Claude Code hooks, Codex hooks, existing integration settings and installers. - ---- - -## Design Summary - -The compiler has two modes: - -- `session_start`: project-continuity context injected on `SessionStart`. -- `prompt_retrieval`: query-aware context injected on `UserPromptSubmit`. - -Both modes use the same deterministic utilities: - -- Normalize Memind rawdata/items/insights into `ContextEntry` dictionaries. -- Classify entries into stable sections. -- Rank entries by section-specific usefulness. -- Deduplicate repeated memory text within each section. Preserve `agent_timeline` captions in `Continue From` because they are turn summaries; do not let caption text remove structured `directive`, `resolution`, or `playbook` items. Allow lower-priority `facts` and generic memory items to be removed when they duplicate higher-value structured items. -- Render with section budgets, per-entry caps, stable source citations, and an explicit historical-memory preamble. - -This plan intentionally avoids core/OpenAPI changes. It uses existing calls: - -- `query_raw_data(types=["agent_timeline"], metadata_filter=project_filter, include={"metadata": True, "segment": False})` -- `query_items(categories=[...], raw_data_types=["agent_timeline"], metadata_filter=project_filter)` -- `memory.retrieve(query=...)` - -## File Structure - -Create: - -- `memind-integrations/claude-code/scripts/lib/context_compiler.py` - - Owns normalization, classification, ranking, dedupe, budgets, and rendering for Claude Code. -- `memind-integrations/codex/scripts/lib/context_compiler.py` - - Same implementation for Codex so the installed adapter is self-contained. -- `memind-integrations/claude-code/tests/test_context_compiler.py` - - Unit tests for compiler behavior independent of hook subprocess tests. -- `memind-integrations/codex/tests/test_context_compiler.py` - - Same coverage for Codex. - -Modify: - -- `memind-integrations/claude-code/scripts/lib/session_context.py` - - Keep fetching project memory, delegate rendering to `context_compiler`. -- `memind-integrations/codex/scripts/lib/session_context.py` - - Same as Claude Code. -- `memind-integrations/claude-code/scripts/retrieve.py` - - Replace local `_format_context` with `compile_prompt_retrieval_context`. -- `memind-integrations/codex/scripts/retrieve.py` - - Same as Claude Code. -- `memind-integrations/claude-code/tests/test_session_context.py` - - Assert fetch behavior remains unchanged and new rendering behavior appears. -- `memind-integrations/codex/tests/test_session_context.py` - - Same as Claude Code. -- `memind-integrations/claude-code/tests/test_hooks.py` - - Update `_format_context` tests to target compiler through `retrieve.py` or move assertions to `test_context_compiler.py`. -- `memind-integrations/codex/tests/test_hooks.py` - - Same as Claude Code. -- `memind-integrations/codex/install.sh` - - Add `scripts/lib/context_compiler.py` to remote install file list. -- `memind-integrations/codex/tests/test_installer.py` - - Assert `context_compiler.py` is included in installer file checks. -- `memind-integrations/claude-code/README.md` - - Update SessionStart and retrieval examples. -- `memind-integrations/codex/README.md` - - Same as Claude Code. - -Do not modify: - -- `memind-core` -- `memind-server` -- `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent` -- OpenAPI schema or generated models - ---- - -## Task 1: Add Shared Compiler Behavior to Claude Code - -**Files:** - -- Create: `memind-integrations/claude-code/scripts/lib/context_compiler.py` -- Create: `memind-integrations/claude-code/tests/test_context_compiler.py` - -- [ ] **Step 1: Write failing tests for session-start compilation** - -Create `memind-integrations/claude-code/tests/test_context_compiler.py` with this starting content: - -```python -import sys -import unittest -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT / "scripts")) - -from scripts.lib.context_compiler import compile_session_start_context - - -class ContextCompilerTest(unittest.TestCase): - def test_session_start_context_ranks_dedupes_and_preserves_priority_sections(self): - context = { - "projectSlug": "memind-main", - "recentRawData": [ - { - "id": "rd-old", - "caption": "Older work on unrelated install docs.", - "createdAt": "2026-05-25T10:00:00Z", - "metadata": {}, - }, - { - "id": "rd-new", - "caption": "Completed SessionStart context injection for Claude Code and Codex; keep userId and agentId stable.", - "createdAt": "2026-05-27T10:00:00Z", - "metadata": {}, - }, - ], - "items": { - "directive": [ - { - "id": "dir-1", - "category": "directive", - "text": "Keep userId and agentId stable; use metadata.projectSlug for project isolation.", - "createdAt": "2026-05-27T09:00:00Z", - "metadata": {}, - } - ], - "watchOut": [ - { - "id": "res-1", - "category": "resolution", - "text": "Codex tests must run with Python 3.12; older Python can fail on modern type syntax.", - "createdAt": "2026-05-27T08:00:00Z", - "metadata": {}, - }, - { - "id": "res-dup", - "category": "resolution", - "text": "codex tests must run with python 3.12 older python can fail on modern type syntax", - "createdAt": "2026-05-26T08:00:00Z", - "metadata": {}, - }, - ], - "playbook": [ - { - "id": "pb-1", - "category": "playbook", - "text": "After changing Claude Code or Codex hooks, run both integration unittest suites and git diff --check.", - "createdAt": "2026-05-27T07:00:00Z", - "metadata": {}, - } - ], - "fact": [ - { - "id": "fact-1", - "category": "event", - "text": "SessionStart is read-only: it queries memory and injects context without writing rawdata.", - "createdAt": "2026-05-27T06:00:00Z", - "metadata": {}, - } - ], - }, - } - - rendered = compile_session_start_context(context, {"sessionContextMaxChars": 6000}) - - self.assertIn('', rendered) - self.assertIn("Historical Memind project memory", rendered) - self.assertIn("Current user instructions and repository files take precedence", rendered) - self.assertIn("Verify old implementation details against the working tree", rendered) - self.assertIn("## Continue From", rendered) - self.assertIn("[rawdata:rd-new, 2026-05-27] Completed SessionStart context injection", rendered) - self.assertLess(rendered.index("rd-new"), rendered.index("rd-old")) - self.assertIn("## Must Follow", rendered) - self.assertIn("[item:dir-1 directive, 2026-05-27] Keep userId and agentId stable", rendered) - self.assertIn("rd-new", rendered, "turn-summary caption should stay in Continue From") - self.assertIn("dir-1", rendered, "structured item should not be removed by caption text") - self.assertIn("## Watch Outs", rendered) - self.assertIn("[item:res-1 resolution, 2026-05-27] Codex tests must run", rendered) - self.assertNotIn("res-dup", rendered) - self.assertIn("## Reusable Playbooks", rendered) - self.assertIn("[item:pb-1 playbook, 2026-05-27] After changing Claude Code", rendered) - self.assertIn("## Useful Facts", rendered) - self.assertIn("[item:fact-1 event, 2026-05-27] SessionStart is read-only", rendered) - self.assertTrue(rendered.endswith("")) - - def test_session_start_context_uses_section_budget_instead_of_naive_line_truncation(self): - context = { - "projectSlug": "memind-main", - "recentRawData": [ - {"id": "rd-1", "caption": "Recent work " + "A" * 500, "createdAt": "2026-05-27T01:00:00Z"}, - ], - "items": { - "directive": [ - {"id": "dir-1", "category": "directive", "text": "Do not break stable identity.", "createdAt": "2026-05-27T01:00:00Z"} - ], - "watchOut": [ - {"id": "res-1", "category": "resolution", "text": "Always avoid committing __pycache__ files.", "createdAt": "2026-05-27T01:00:00Z"} - ], - "playbook": [ - {"id": "pb-1", "category": "playbook", "text": "Run focused integration tests after hook changes.", "createdAt": "2026-05-27T01:00:00Z"} - ], - "fact": [ - {"id": "fact-1", "category": "event", "text": "Fact " + "F" * 400, "createdAt": "2026-05-27T01:00:00Z"} - ], - }, - } - - rendered = compile_session_start_context(context, {"sessionContextMaxChars": 900}) - - self.assertLessEqual(len(rendered), 900) - self.assertIn("## Must Follow", rendered) - self.assertIn("Do not break stable identity.", rendered) - self.assertIn("## Watch Outs", rendered) - self.assertIn("Always avoid committing __pycache__", rendered) - self.assertIn("truncated: lower-priority memories omitted", rendered) - self.assertTrue(rendered.endswith("")) - - def test_session_start_context_returns_empty_when_no_entries_exist(self): - rendered = compile_session_start_context( - {"projectSlug": "memind-main", "recentRawData": [], "items": {}}, - {"sessionContextMaxChars": 6000}, - ) - - self.assertEqual(rendered, "") - - -if __name__ == "__main__": - unittest.main() -``` - -- [ ] **Step 2: Run the failing compiler tests** - -Run: - -```bash -python3 -m unittest tests/test_context_compiler.py -v -``` - -Expected: FAIL with `ModuleNotFoundError: No module named 'scripts.lib.context_compiler'`. - -- [ ] **Step 3: Implement Claude Code context compiler** - -Create `memind-integrations/claude-code/scripts/lib/context_compiler.py`: - -```python -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -import html -import re -import string -from datetime import datetime - -DEFAULT_MAX_CHARS = 6000 -DEFAULT_SESSION_ENTRY_MAX_CHARS = 520 -DEFAULT_RETRIEVAL_ENTRY_MAX_CHARS = 700 - -SESSION_SECTION_ORDER = [ - ("continueFrom", "## Continue From"), - ("mustFollow", "## Must Follow"), - ("watchOuts", "## Watch Outs"), - ("playbooks", "## Reusable Playbooks"), - ("facts", "## Useful Facts"), -] - -SESSION_SECTION_BUDGETS = { - "continueFrom": 1300, - "mustFollow": 1300, - "watchOuts": 1400, - "playbooks": 1200, - "facts": 800, -} - -PROMPT_SECTION_ORDER = [ - ("directives", "## Directives"), - ("resolvedProblems", "## Resolved Problems"), - ("playbooks", "## Agent Playbooks"), - ("toolNotes", "## Tool Notes"), - ("insights", "## Insights"), - ("memoryItems", "## Memory Items"), -] - -PROMPT_SECTION_BUDGETS = { - "directives": 900, - "resolvedProblems": 1600, - "playbooks": 1200, - "toolNotes": 900, - "insights": 800, - "memoryItems": 600, -} - -PROMPT_SECTION_LIMITS = { - "directives": 3, - "resolvedProblems": 5, - "playbooks": 4, - "toolNotes": 3, - "insights": 3, - "memoryItems": 3, -} - -WATCH_OUT_TERMS = { - "error", - "failed", - "failure", - "fix", - "fixed", - "regression", - "test", - "timeout", - "retry", - "avoid", -} - -PLAYBOOK_TERMS = {"run", "after", "before", "when", "then", "workflow", "steps", "verify"} - - -def compile_session_start_context(context, config): - sections = { - "continueFrom": _normalize_rawdata(context.get("recentRawData") or []), - "mustFollow": _normalize_items(((context.get("items") or {}).get("directive") or []), "mustFollow"), - "watchOuts": _normalize_items(((context.get("items") or {}).get("watchOut") or []), "watchOuts"), - "playbooks": _normalize_items(((context.get("items") or {}).get("playbook") or []), "playbooks"), - "facts": _normalize_items(((context.get("items") or {}).get("fact") or []), "facts"), - } - project_slug = context.get("projectSlug") or "unknown" - max_chars = int(config.get("sessionContextMaxChars", DEFAULT_MAX_CHARS)) - entry_max_chars = int(config.get("sessionContextEntryMaxChars", DEFAULT_SESSION_ENTRY_MAX_CHARS)) - return _render_context( - wrapper="memind_session_context", - attrs={"project": project_slug}, - preamble=( - "Historical Memind project memory. Use only when directly helpful. " - "Current user instructions and repository files take precedence. " - "Verify old implementation details against the working tree before relying on them." - ), - sections=_prepare_sections(sections, "session_start"), - order=SESSION_SECTION_ORDER, - budgets=SESSION_SECTION_BUDGETS, - max_chars=max_chars, - entry_max_chars=entry_max_chars, - ) - - -def compile_prompt_retrieval_context(data, config): - max_entries = int(config.get("retrieveMaxEntries", 8)) - max_chars = int(config.get("retrieveMaxChars", DEFAULT_MAX_CHARS)) - entry_max_chars = int(config.get("retrieveEntryMaxChars", DEFAULT_RETRIEVAL_ENTRY_MAX_CHARS)) - - items = [_normalize_retrieved_item(item) for item in data.get("items") or [] if _field(item, "text")] - insights = [_normalize_insight(insight) for insight in data.get("insights") or [] if _field(insight, "text")] - sorted_items = _sort_prompt_items(items) - - sections = { - "directives": _top_category(sorted_items, "directive", _section_limit("directives", max_entries)), - "resolvedProblems": _top_category(sorted_items, "resolution", _section_limit("resolvedProblems", max_entries)), - "playbooks": _top_category(sorted_items, "playbook", _section_limit("playbooks", max_entries)), - "toolNotes": _top_category(sorted_items, "tool", _section_limit("toolNotes", max_entries)), - "insights": _sort_insights(insights)[: _section_limit("insights", max_entries)], - "memoryItems": _top_general_items(sorted_items, _section_limit("memoryItems", max_entries)), - } - - degraded_notice = "" - if data.get("status") == "degraded": - degraded_notice = "[Note: Memory retrieval encountered an error. Results may be incomplete.]" - - preamble = config.get("retrievePromptPreamble") or ( - "Relevant Memind memories for the current request. Use only when directly helpful." - ) - rendered = _render_context( - wrapper="memind_memories", - attrs={}, - preamble=preamble, - sections=_prepare_sections(sections, "prompt_retrieval"), - order=PROMPT_SECTION_ORDER, - budgets=PROMPT_SECTION_BUDGETS, - max_chars=max_chars, - entry_max_chars=entry_max_chars, - trailing_notice=degraded_notice, - ) - if rendered or not degraded_notice: - return rendered - return _render_context( - wrapper="memind_memories", - attrs={}, - preamble=preamble, - sections={}, - order=PROMPT_SECTION_ORDER, - budgets=PROMPT_SECTION_BUDGETS, - max_chars=max_chars, - entry_max_chars=entry_max_chars, - trailing_notice=degraded_notice, - allow_notice_only=True, - ) - - -def _prepare_sections(sections, mode): - prepared = {} - high_value_seen = set() - for key, entries in sections.items(): - ranked = _rank_entries(entries, key, mode) - deduped = [] - section_seen = set() - for entry in ranked: - dedupe_key = _dedupe_key(entry["text"]) - if not dedupe_key or dedupe_key in section_seen: - continue - if key in {"facts", "memoryItems"} and dedupe_key in high_value_seen: - continue - section_seen.add(dedupe_key) - deduped.append(entry) - if key not in {"continueFrom", "facts", "memoryItems"}: - high_value_seen.update(_dedupe_key(entry["text"]) for entry in deduped if _dedupe_key(entry["text"])) - if deduped: - prepared[key] = deduped - return prepared - - -def _normalize_rawdata(raw_data): - entries = [] - for raw in raw_data: - text = _field(raw, "caption") - if not text: - continue - entries.append( - { - "kind": "rawdata", - "id": _field(raw, "id"), - "category": "agent_timeline", - "text": _clean(text), - "createdAt": _field(raw, "createdAt") or _field(raw, "created_at"), - "score": 0, - } - ) - return entries - - -def _normalize_items(items, section): - entries = [] - for item in items: - text = _field(item, "text") - if not text: - continue - category = str(_field(item, "category") or "memory").strip().lower() - entries.append( - { - "kind": "item", - "id": _field(item, "id"), - "category": category, - "text": _clean(text), - "createdAt": _field(item, "createdAt") or _field(item, "created_at"), - "score": _section_score(section, text), - } - ) - return entries - - -def _normalize_retrieved_item(item): - category = str(_field(item, "category") or "memory").strip().lower() - return { - "kind": "item", - "id": _field(item, "id"), - "category": category, - "text": _clean(_field(item, "text")), - "createdAt": _field(item, "createdAt") or _field(item, "created_at"), - "score": _number(_field(item, "finalScore"), _field(item, "vectorScore"), 0), - } - - -def _normalize_insight(insight): - return { - "kind": "insight", - "id": _field(insight, "id"), - "category": str(_field(insight, "tier") or "insight").strip().lower(), - "text": _clean(_field(insight, "text")), - "createdAt": _field(insight, "createdAt") or _field(insight, "created_at"), - "score": 0, - } - - -def _rank_entries(entries, section, mode): - if section == "continueFrom": - return sorted(entries, key=lambda entry: _timestamp(entry.get("createdAt")), reverse=True)[:3] - return sorted( - entries, - key=lambda entry: ( - entry.get("score", 0), - _timestamp(entry.get("createdAt")), - -len(entry.get("text", "")), - ), - reverse=True, - ) - - -def _sort_prompt_items(items): - return sorted( - items, - key=lambda entry: ( - entry.get("score", 0), - _timestamp(entry.get("createdAt")), - _category_priority(entry.get("category")), - ), - reverse=True, - ) - - -def _sort_insights(insights): - tier_rank = {"root": 3, "branch": 2, "leaf": 1} - return sorted( - insights, - key=lambda entry: ( - tier_rank.get(entry.get("category", ""), 0), - _timestamp(entry.get("createdAt")), - str(entry.get("id") or ""), - ), - reverse=True, - ) - - -def _top_category(items, category, limit): - return [entry for entry in items if entry["category"] == category][:limit] - - -def _top_general_items(items, limit): - agent_categories = {"directive", "resolution", "playbook", "tool"} - return [entry for entry in items if entry["category"] not in agent_categories][:limit] - - -def _section_limit(section, max_entries): - return max(0, min(PROMPT_SECTION_LIMITS.get(section, max_entries), max_entries)) - - -def _category_priority(category): - return {"directive": 5, "resolution": 4, "playbook": 3, "tool": 2}.get(category or "", 1) - - -def _section_score(section, text): - lowered = str(text or "").lower() - if section == "watchOuts": - return sum(1 for term in WATCH_OUT_TERMS if term in lowered) - if section == "playbooks": - return sum(1 for term in PLAYBOOK_TERMS if term in lowered) - if section == "mustFollow": - return 2 if len(lowered) <= 220 else 1 - return 0 - - -def _render_context( - wrapper, - attrs, - preamble, - sections, - order, - budgets, - max_chars, - entry_max_chars, - trailing_notice="", - allow_notice_only=False, -): - if not sections and not trailing_notice and not allow_notice_only: - return "" - - open_tag = _open_tag(wrapper, attrs) - close_tag = f"" - fixed_lines = [open_tag, preamble] - rendered_sections = [] - truncated = False - - for key, title in order: - entries = sections.get(key) or [] - if not entries: - continue - budget = budgets.get(key, 800) - lines, section_truncated = _render_section(title, entries, budget, entry_max_chars) - truncated = truncated or section_truncated - if lines: - rendered_sections.append(lines) - - if trailing_notice: - rendered_sections.append([trailing_notice]) - - if not rendered_sections and not allow_notice_only: - return "" - - lines = list(fixed_lines) - for section_lines in rendered_sections: - lines.append("") - lines.extend(section_lines) - if truncated: - lines.append("") - lines.append("[truncated: lower-priority memories omitted]") - lines.append(close_tag) - - rendered = "\n".join(lines) - if len(rendered) <= max_chars: - return rendered - return _fit_to_total_budget(lines, close_tag, max_chars) - - -def _render_section(title, entries, budget, entry_max_chars): - lines = [title] - used = len(title) - truncated = False - for entry in entries: - line = _render_entry(entry, entry_max_chars) - addition = len(line) + 1 - if used + addition > budget: - truncated = True - break - lines.append(line) - used += addition - return (lines if len(lines) > 1 else []), truncated - - -def _render_entry(entry, max_chars): - date = _date_label(entry.get("createdAt")) - if entry["kind"] == "rawdata": - label = f"rawdata:{entry.get('id')}" - elif entry["kind"] == "insight": - label = f"insight:{entry.get('id')} {entry.get('category') or 'insight'}" - else: - label = f"item:{entry.get('id')} {entry.get('category') or 'memory'}" - if date: - label = f"{label}, {date}" - return f"- [{label}] {_clip(entry.get('text'), max_chars)}" - - -def _fit_to_total_budget(lines, close_tag, max_chars): - notice = "[truncated: lower-priority memories omitted]" - full_suffix = f"\n{notice}\n{close_tag}" - suffix = full_suffix if len(full_suffix) < max_chars else f"\n{close_tag}" - selected = [] - budget = max(0, max_chars - len(suffix)) - used = 0 - for line in lines: - if line == close_tag or line == notice: - continue - addition = len(line) + (1 if selected else 0) - if used + addition > budget: - break - selected.append(line) - used += addition - prefix = "\n".join(selected) - if len(prefix) > budget: - prefix = prefix[:budget].rstrip() - result = f"{prefix}{suffix}" if prefix else suffix.lstrip() - if len(result) <= max_chars: - return result - overflow = len(result) - max_chars - prefix = prefix[:-overflow].rstrip() if overflow < len(prefix) else "" - return f"{prefix}{suffix}" if prefix else suffix.lstrip() - - -def _open_tag(wrapper, attrs): - if not attrs: - return f"<{wrapper}>" - rendered = " ".join( - f'{name}="{html.escape(str(value), quote=True)}"' for name, value in attrs.items() - ) - return f"<{wrapper} {rendered}>" - - -def _field(value, name): - if isinstance(value, dict): - return value.get(name) - return getattr(value, name, None) - - -def _clean(value): - return " ".join(str(value or "").split()) - - -def _clip(value, max_chars): - cleaned = _clean(value) - if len(cleaned) <= max_chars: - return cleaned - return cleaned[: max(0, max_chars - 12)].rstrip() + " [truncated]" - - -def _dedupe_key(value): - cleaned = _clean(value).lower() - cleaned = cleaned.translate(str.maketrans("", "", string.punctuation)) - cleaned = re.sub(r"\s+", " ", cleaned).strip() - return cleaned[:260] - - -def _timestamp(value): - if not value: - return 0 - try: - return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp() - except ValueError: - return 0 - - -def _date_label(value): - if not value: - return "" - text = str(value) - return text[:10] if len(text) >= 10 else text - - -def _number(*values): - for value in values: - if value is None: - continue - try: - return float(value) - except (TypeError, ValueError): - continue - return 0 -``` - -- [ ] **Step 4: Run Claude Code compiler tests** - -Run: - -```bash -python3 -m unittest tests/test_context_compiler.py -v -``` - -Expected: PASS. - -- [ ] **Step 5: Commit Task 1** - -Run: - -```bash -git add memind-integrations/claude-code/scripts/lib/context_compiler.py memind-integrations/claude-code/tests/test_context_compiler.py -git commit -m "feat: add claude code agent context compiler" -``` - -Expected: commit succeeds. - ---- - -## Task 2: Wire Claude Code SessionStart to the Compiler - -**Files:** - -- Modify: `memind-integrations/claude-code/scripts/lib/session_context.py` -- Modify: `memind-integrations/claude-code/tests/test_session_context.py` - -- [ ] **Step 1: Update failing SessionStart expectations** - -Modify `memind-integrations/claude-code/tests/test_session_context.py`: - -```python -from scripts.lib.session_context import build_session_context, render_session_context -``` - -Keep the import as-is, then update `test_build_session_context_queries_current_project_memory` assertions to include the improved preamble and citations: - -```python -self.assertIn("Historical Memind project memory", rendered) -self.assertIn("Current user instructions and repository files take precedence", rendered) -self.assertIn("Verify old implementation details against the working tree", rendered) -self.assertIn("[rawdata:rd-1, 2026-05-27] Implemented generic memory query APIs", rendered) -self.assertIn("[item:it-1 directive] Keep userId and agentId stable.", rendered) -``` - -Keep the existing API call assertions unchanged. - -- [ ] **Step 2: Run SessionStart tests to verify failure** - -Run: - -```bash -python3 -m unittest tests/test_session_context.py -v -``` - -Expected: FAIL because `render_session_context` still emits the old preamble and rawdata citations without dates. - -- [ ] **Step 3: Delegate rendering from session_context to context_compiler** - -Modify `memind-integrations/claude-code/scripts/lib/session_context.py`. - -Add import near the top: - -```python -from lib.context_compiler import compile_session_start_context -``` - -Replace the body of `render_session_context` with: - -```python -def render_session_context(context, config): - return compile_session_start_context(context, config) -``` - -Delete these old rendering-only symbols from `session_context.py` after `render_session_context` delegates to the compiler: - -- `html` -- `SECTION_ORDER` -- `_section_entries` -- `_render_entry` -- `_truncate_lines` -- `_clean` - -Keep fetch helpers: - -- `DEFAULT_RECENT_SESSIONS` -- `DEFAULT_MAX_ITEMS` -- `DEFAULT_MAX_CHARS` -- `project_metadata_filter` -- `build_session_context` -- `_query_items` -- `_raw_data_entry` -- `_item_entry` -- `_field` -- `_text` - -- [ ] **Step 4: Run Claude Code SessionStart tests** - -Run: - -```bash -python3 -m unittest tests/test_session_context.py -v -``` - -Expected: PASS. - -- [ ] **Step 5: Run all Claude Code integration tests** - -Run: - -```bash -python3 -m unittest discover -s tests -``` - -Expected: `Ran ... tests` and `OK`. - -- [ ] **Step 6: Commit Task 2** - -Run: - -```bash -git add memind-integrations/claude-code/scripts/lib/session_context.py memind-integrations/claude-code/tests/test_session_context.py -git commit -m "feat: compile claude code session context" -``` - -Expected: commit succeeds. - ---- - -## Task 3: Wire Claude Code Prompt Retrieval to the Compiler - -**Files:** - -- Modify: `memind-integrations/claude-code/scripts/retrieve.py` -- Modify: `memind-integrations/claude-code/tests/test_hooks.py` -- Modify: `memind-integrations/claude-code/tests/test_context_compiler.py` - -- [ ] **Step 1: Add failing prompt-retrieval compiler tests** - -Append to `ContextCompilerTest` in `memind-integrations/claude-code/tests/test_context_compiler.py`: - -```python - def test_prompt_retrieval_context_groups_agent_categories_by_execution_value(self): - from scripts.lib.context_compiler import compile_prompt_retrieval_context - - data = { - "insights": [ - {"id": "ins-leaf", "text": "Leaf insight", "tier": "LEAF"}, - {"id": "ins-root", "text": "Root insight", "tier": "ROOT"}, - ], - "items": [ - {"id": "tool-1", "text": "Use mvn -pl memind-server test for server checks.", "category": "tool", "finalScore": 0.6}, - {"id": "res-1", "text": "Retry spool events are cleared only after successful agent_timeline extraction.", "category": "resolution", "finalScore": 0.9}, - {"id": "pb-1", "text": "When hooks change, run both integration test suites.", "category": "playbook", "finalScore": 0.8}, - {"id": "dir-1", "text": "Do not default Claude Code or Codex to conversation rawdata.", "category": "directive", "finalScore": 0.7}, - {"id": "ev-1", "text": "rawdata-agent emits agent_episode segment metadata.", "category": "event", "finalScore": 0.5}, - {"id": "ev-high", "text": "A high-scoring general fact should not crowd out agent-specific sections.", "category": "event", "finalScore": 0.99}, - ], - } - - rendered = compile_prompt_retrieval_context( - data, - {"retrieveMaxEntries": 8, "retrieveMaxChars": 6000, "retrievePromptPreamble": "Relevant memories from Memind."}, - ) - - self.assertIn("", rendered) - self.assertIn("## Directives", rendered) - self.assertIn("[item:dir-1 directive] Do not default Claude Code", rendered) - self.assertIn("## Resolved Problems", rendered) - self.assertIn("[item:res-1 resolution] Retry spool events", rendered) - self.assertIn("## Agent Playbooks", rendered) - self.assertIn("[item:pb-1 playbook] When hooks change", rendered) - self.assertIn("## Tool Notes", rendered) - self.assertIn("[item:tool-1 tool] Use mvn", rendered) - self.assertIn("## Insights", rendered) - self.assertLess(rendered.index("ins-root"), rendered.index("ins-leaf")) - self.assertIn("## Memory Items", rendered) - self.assertIn("[item:ev-high event] A high-scoring general fact", rendered) - self.assertIn("[item:ev-1 event] rawdata-agent emits", rendered) - self.assertTrue(rendered.endswith("")) - - def test_prompt_retrieval_context_keeps_degraded_notice(self): - from scripts.lib.context_compiler import compile_prompt_retrieval_context - - rendered = compile_prompt_retrieval_context( - {"status": "degraded"}, - {"retrieveMaxEntries": 8, "retrieveMaxChars": 1000, "retrievePromptPreamble": ""}, - ) - - self.assertIn("Memory retrieval encountered an error", rendered) - self.assertIn("", rendered) -``` - -- [ ] **Step 2: Run compiler tests** - -Run: - -```bash -python3 -m unittest tests/test_context_compiler.py -v -``` - -Expected: PASS. Task 1 already defines `compile_prompt_retrieval_context`; a failure here means the Task 1 implementation diverged from this plan and must be corrected before wiring `retrieve.py`. - -- [ ] **Step 3: Update retrieve.py to use the compiler** - -Modify `memind-integrations/claude-code/scripts/retrieve.py`. - -Add import: - -```python -from lib.context_compiler import compile_prompt_retrieval_context -``` - -Replace `_format_context` with: - -```python -def _format_context(data, config): - return compile_prompt_retrieval_context(data, config) -``` - -Delete these old local formatter symbols from `retrieve.py` after `_format_context` delegates to the compiler: - -- `AGENT_CATEGORY_SECTIONS` -- `_item_category` -- `_group_agent_items` - -- [ ] **Step 4: Update hook formatter tests for new section order** - -In `memind-integrations/claude-code/tests/test_hooks.py`, update `test_format_context_groups_agent_memory_categories` expectations: - -```python -self.assertIn("## Directives", context) -self.assertIn("## Resolved Problems", context) -self.assertIn("## Agent Playbooks", context) -self.assertIn("## Tool Notes", context) -self.assertLess(context.index("## Directives"), context.index("## Resolved Problems")) -self.assertLess(context.index("## Resolved Problems"), context.index("## Agent Playbooks")) -``` - -Keep degraded retrieval tests unchanged. - -- [ ] **Step 5: Run Claude Code tests** - -Run: - -```bash -python3 -m unittest discover -s tests -``` - -Expected: `Ran ... tests` and `OK`. - -- [ ] **Step 6: Commit Task 3** - -Run: - -```bash -git add memind-integrations/claude-code/scripts/retrieve.py memind-integrations/claude-code/tests/test_hooks.py memind-integrations/claude-code/tests/test_context_compiler.py -git commit -m "feat: compile claude code prompt retrieval context" -``` - -Expected: commit succeeds. - ---- - -## Task 4: Port Compiler to Codex - -**Files:** - -- Create: `memind-integrations/codex/scripts/lib/context_compiler.py` -- Create: `memind-integrations/codex/tests/test_context_compiler.py` - -- [ ] **Step 1: Copy Claude Code compiler implementation to Codex** - -Copy the final content of: - -```text -memind-integrations/claude-code/scripts/lib/context_compiler.py -``` - -to: - -```text -memind-integrations/codex/scripts/lib/context_compiler.py -``` - -The file must remain self-contained and import only standard library modules. - -- [ ] **Step 2: Copy compiler tests to Codex and adjust product-specific strings** - -Create `memind-integrations/codex/tests/test_context_compiler.py` from the Claude Code test file. It must keep the same test method names and the same behavioral assertions so Claude Code and Codex cannot drift. Use Codex-specific examples where text names the host: - -```python -"Completed SessionStart context injection for Codex." -"After changing Codex hooks, run the Codex integration unittest suite and git diff --check." -``` - -The import must be: - -```python -from scripts.lib.context_compiler import compile_session_start_context -``` - -Add this parity test to the Codex file so future changes keep the public compiler API aligned: - -```python - def test_compiler_exports_match_claude_code_contract(self): - import scripts.lib.context_compiler as compiler - - self.assertTrue(callable(compiler.compile_session_start_context)) - self.assertTrue(callable(compiler.compile_prompt_retrieval_context)) -``` - -- [ ] **Step 3: Run Codex compiler tests** - -Run: - -```bash -UV_CACHE_DIR=.uv-cache uv run --python /opt/homebrew/bin/python3.12 python -m unittest tests/test_context_compiler.py -v -``` - -Expected: PASS. - -- [ ] **Step 4: Commit Task 4** - -Run: - -```bash -git add memind-integrations/codex/scripts/lib/context_compiler.py memind-integrations/codex/tests/test_context_compiler.py -git commit -m "feat: add codex agent context compiler" -``` - -Expected: commit succeeds. - ---- - -## Task 5: Wire Codex SessionStart and Prompt Retrieval - -**Files:** - -- Modify: `memind-integrations/codex/scripts/lib/session_context.py` -- Modify: `memind-integrations/codex/scripts/retrieve.py` -- Modify: `memind-integrations/codex/tests/test_session_context.py` -- Modify: `memind-integrations/codex/tests/test_hooks.py` - -- [ ] **Step 1: Wire Codex session_context.py** - -Modify `memind-integrations/codex/scripts/lib/session_context.py` with the same wiring used in Claude Code: - -```python -from lib.context_compiler import compile_session_start_context -``` - -Replace `render_session_context` with: - -```python -def render_session_context(context, config): - return compile_session_start_context(context, config) -``` - -Delete these old rendering-only symbols from Codex `session_context.py` after `render_session_context` delegates to the compiler: - -- `html` -- `SECTION_ORDER` -- `_section_entries` -- `_render_entry` -- `_truncate_lines` -- `_clean` - -- [ ] **Step 2: Wire Codex retrieve.py** - -Modify `memind-integrations/codex/scripts/retrieve.py`. - -Add: - -```python -from lib.context_compiler import compile_prompt_retrieval_context -``` - -Replace `_format_context` with: - -```python -def _format_context(data, config): - return compile_prompt_retrieval_context(data, config) -``` - -Delete these old local formatter symbols from Codex `retrieve.py` after `_format_context` delegates to the compiler: - -- `AGENT_CATEGORY_SECTIONS` -- `_item_category` -- `_group_agent_items` - -- [ ] **Step 3: Update Codex SessionStart tests** - -In `memind-integrations/codex/tests/test_session_context.py`, update the fake rawdata to include a deterministic timestamp: - -```python -types.SimpleNamespace( - id="rd-1", - caption="Fixed Codex agent timeline flushing.", - metadata={}, - created_at="2026-05-27T01:00:00Z", -) -``` - -Then update rendered assertions to expect the improved preamble and dated rawdata citation: - -```python -self.assertIn("Historical Memind project memory", rendered) -self.assertIn("Current user instructions and repository files take precedence", rendered) -self.assertIn("Verify old implementation details against the working tree", rendered) -self.assertIn("[rawdata:rd-1, 2026-05-27] Fixed Codex", rendered) -``` - -- [ ] **Step 4: Update Codex hook formatter tests** - -In `memind-integrations/codex/tests/test_hooks.py`, update `test_format_context_groups_agent_memory_categories` with the same section-order assertions used for Claude Code: - -```python -self.assertIn("## Directives", context) -self.assertIn("## Resolved Problems", context) -self.assertIn("## Agent Playbooks", context) -self.assertIn("## Tool Notes", context) -``` - -- [ ] **Step 5: Run Codex tests** - -Run: - -```bash -UV_CACHE_DIR=.uv-cache uv run --python /opt/homebrew/bin/python3.12 python -m unittest discover -s tests -``` - -Expected: `Ran ... tests` and `OK`. - -- [ ] **Step 6: Commit Task 5** - -Run: - -```bash -git add memind-integrations/codex/scripts/lib/session_context.py memind-integrations/codex/scripts/retrieve.py memind-integrations/codex/tests/test_session_context.py memind-integrations/codex/tests/test_hooks.py -git commit -m "feat: compile codex memory context" -``` - -Expected: commit succeeds. - ---- - -## Task 6: Update Codex Installer and Configuration Tests - -**Files:** - -- Modify: `memind-integrations/codex/install.sh` -- Modify: `memind-integrations/codex/tests/test_installer.py` - -- [ ] **Step 1: Add failing installer test for context compiler file** - -Add this test method to `InstallerTest` in `memind-integrations/codex/tests/test_installer.py`. This must read `install.sh` directly because local `--source-root` installation copies the whole `scripts/` directory and would not catch a missing remote download list entry: - -```python - def test_remote_install_file_list_includes_context_compiler(self): - install_script = (ROOT / "install.sh").read_text() - - self.assertIn('"scripts/lib/context_compiler.py"', install_script) -``` - -- [ ] **Step 2: Run installer tests to verify failure** - -Run: - -```bash -UV_CACHE_DIR=.uv-cache uv run --python /opt/homebrew/bin/python3.12 python -m unittest tests/test_installer.py -v -``` - -Expected: FAIL because `install.sh` does not yet include `scripts/lib/context_compiler.py`. - -- [ ] **Step 3: Add context_compiler.py to installer file list** - -Modify `memind-integrations/codex/install.sh` in `download_remote_install()` and insert: - -```bash -"scripts/lib/context_compiler.py" -``` - -near the other `scripts/lib/*.py` entries. - -- [ ] **Step 4: Run installer tests** - -Run: - -```bash -UV_CACHE_DIR=.uv-cache uv run --python /opt/homebrew/bin/python3.12 python -m unittest tests/test_installer.py -v -``` - -Expected: PASS. - -- [ ] **Step 5: Commit Task 6** - -Run: - -```bash -git add memind-integrations/codex/install.sh memind-integrations/codex/tests/test_installer.py -git commit -m "fix: install codex context compiler" -``` - -Expected: commit succeeds. - ---- - -## Task 7: Update Documentation and Examples - -**Files:** - -- Modify: `memind-integrations/claude-code/README.md` -- Modify: `memind-integrations/codex/README.md` - -- [ ] **Step 1: Update SessionStart context example in Claude Code README** - -In `memind-integrations/claude-code/README.md`, replace the existing SessionStart context example with: - -```text - -Historical Memind project memory. Use only when directly helpful. Current user instructions and repository files take precedence. Verify old implementation details against the working tree before relying on them. - -## Continue From -- [rawdata:rd-1, 2026-05-27] Completed SessionStart context injection for Claude Code and Codex. - -## Must Follow -- [item:101 directive, 2026-05-27] Keep userId and agentId stable; use metadata.projectSlug for project isolation. - -## Watch Outs -- [item:102 resolution, 2026-05-27] Codex tests must run with Python 3.12; older Python can fail on modern type syntax. - -## Reusable Playbooks -- [item:103 playbook, 2026-05-27] After changing Claude Code or Codex hooks, run both integration unittest suites and git diff --check. - -## Useful Facts -- [item:104 event, 2026-05-27] SessionStart is read-only: it queries memory and injects context without writing rawdata. - -``` - -- [ ] **Step 2: Update prompt retrieval example in Claude Code README** - -In the retrieval section, update the `` example to show: - -```text - -Relevant memories from Memind. Use only when directly helpful: - -## Directives -- [item:201 directive] Do not default Claude Code or Codex to conversation rawdata. - -## Resolved Problems -- [item:202 resolution] Retry spool events are cleared only after successful agent_timeline extraction. - -## Agent Playbooks -- [item:203 playbook] When hooks change, run both integration test suites and git diff --check. - -## Tool Notes -- [item:204 tool] Use Python 3.12 for the Codex integration test suite. - -## Insights -- [insight:301 root] Coding-agent integrations share memory through stable userId and agentId. - -## Memory Items -- [item:205 event] rawdata-agent emits agent_episode segment metadata. - -``` - -- [ ] **Step 3: Mirror documentation updates in Codex README** - -Apply the same structure to `memind-integrations/codex/README.md`, using Codex wording where the surrounding text references the host. - -- [ ] **Step 4: Commit Task 7** - -Run: - -```bash -git add memind-integrations/claude-code/README.md memind-integrations/codex/README.md -git commit -m "docs: explain agent context compiler output" -``` - -Expected: commit succeeds. - ---- - -## Task 8: Final Verification - -**Files:** - -- Verify all changed files. - -- [ ] **Step 1: Run Claude Code full test suite** - -Run: - -```bash -python3 -m unittest discover -s tests -``` - -from: - -```text -memind-integrations/claude-code -``` - -Expected: `OK`. - -- [ ] **Step 2: Run Codex full test suite** - -Run: - -```bash -UV_CACHE_DIR=.uv-cache uv run --python /opt/homebrew/bin/python3.12 python -m unittest discover -s tests -``` - -from: - -```text -memind-integrations/codex -``` - -Expected: `OK`. - -- [ ] **Step 3: Check whitespace** - -Run: - -```bash -git diff --check -``` - -Expected: no output and exit code 0. - -- [ ] **Step 4: Check staged/untracked files** - -Run: - -```bash -git status --short --branch -``` - -Expected: - -- Current branch remains `feat/rawdata-agent-memory`. -- No `__pycache__` or `.pyc` files are staged. -- Only intentional source, test, installer, and README changes appear. - -- [ ] **Step 5: Push final branch** - -Run: - -```bash -git push -``` - -Expected: remote `feat/rawdata-agent-memory` advances successfully. - ---- - -## Self-Review Checklist - -- Spec coverage: - - SessionStart project-continuity compiler: Tasks 1, 2, 4, 5. - - Prompt retrieval compiler: Tasks 1, 3, 4, 5. - - Claude Code and Codex parity: Tasks 4 and 5. - - No Memind core/OpenAPI/rawdata-agent changes: File Structure and all tasks. - - Installer safety for Codex: Task 6. - - Documentation and examples: Task 7. - - Verification: Task 8. -- Placeholder scan: no unfinished placeholder markers or unspecified implementation placeholders are present. -- Type consistency: - - Public compiler functions are `compile_session_start_context(context, config)` and `compile_prompt_retrieval_context(data, config)`. - - Context keys stay compatible with existing `session_context.py`: `recentRawData`, `items.directive`, `items.watchOut`, `items.playbook`, `items.fact`. - - Existing config keys remain valid: `sessionContextMaxChars`, `retrieveMaxEntries`, `retrieveMaxChars`, `retrievePromptPreamble`. diff --git a/docs/superpowers/plans/2026-05-28-agent-pre-tool-use-context.md b/docs/superpowers/plans/2026-05-28-agent-pre-tool-use-context.md deleted file mode 100644 index b0f35079..00000000 --- a/docs/superpowers/plans/2026-05-28-agent-pre-tool-use-context.md +++ /dev/null @@ -1,2733 +0,0 @@ -# Agent PreToolUse Context Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Inject compact file/tool-aware Memind context immediately before high-value Claude Code and Codex tool calls. - -**Architecture:** Keep the existing `rawdata-agent` storage and extraction model unchanged. `PreToolUse` continues to buffer normalized tool-start events, then optionally performs a small retrieval against existing `tool`, `resolution`, `playbook`, `directive`, and `agent_episode` data and compiles a bounded `` block. No additional LLM calls, no `rawdata-toolcall` double-ingestion, and no OpenAPI schema changes are required for v1. - -**Tech Stack:** Python integration hooks, Memind Python client, OpenAPI item/rawdata query endpoints, existing metadata filter operators, unittest, JSON hook manifests. - ---- - -## Scope And Non-Goals - -In scope: - -- Add a PreToolUse context path for Claude Code and Codex. -- Keep PreToolUse ingestion behavior: every PreToolUse still appends a normalized `agent_timeline` event to local durable state. -- Query existing Memind data by `userId + agentId`, current project slug, current file path, current command, and current tool name. -- Compile a small context block with: - - `Prior Resolutions` - - `Validation Notes` - - `Relevant Playbooks` - - `Directives` - - `Recent Evidence` -- Use `toolRecords`, `toolStats`, and `toolGroups` only as internal evidence for ranking and concise summaries. -- Default token budget target: roughly 500-800 tokens, with `toolContextMaxChars=3500`. -- Fail open if Memind is unavailable, no high-value target exists, or no useful context is found. - -Out of scope: - -- No `rawdata-agent` Java storage model changes. -- No `rawdata-toolcall` ingestion or LLM extraction inside PreToolUse. -- No new OpenAPI endpoints or metadata-filter operators. -- No per-tool LLM observation extraction. -- No blocking or denying tool execution. -- No injection for low-value read/search/list tools in v1. - -## Key Design Decisions - -1. **PreToolUse is a local, exact-context compiler, not another broad memory retrieval.** - `UserPromptSubmit` already injects task-level memories. PreToolUse should only inject context specific to the imminent file edit or command. - -2. **Claude Code PreToolUse must become synchronous.** - The current Claude Code manifest marks `PreToolUse` as async. An async hook is suitable for telemetry buffering but cannot reliably inject `additionalContext` before the tool executes. This plan removes `async` only from Claude Code `PreToolUse`; `PostToolUse`, `Stop`, `Notification`, and `SubagentStop` stay async. - -3. **Codex already keeps hooks synchronous in the current manifest.** - Codex PreToolUse will call the new context compiler from its existing synchronous hook path, while preserving its - current manifest contract. - -4. **No new Memind core API is required for v1.** - Existing top-level metadata fields (`files`, `commands`, `toolNames`, `projectSlug`) are enough for exact filters. Nested `toolRecords` and `toolStats` are read from returned metadata for evidence summaries, not used as required server-side filters. - -5. **Structured query first, semantic retrieve fallback second.** - Exact `query_items` / `query_raw_data` calls are preferred for file/command/tool matches. A bounded `retrieve` fallback is used only when exact queries do not return enough usable items. - -6. **The context compiler hides raw telemetry.** - `durationMs`, `inputTokens`, and `outputTokens` should not be rendered. `toolStats` and `toolRecords` are summarized as validation evidence only. - -## File Map - -- Modify `memind-integrations/claude-code/hooks/hooks.json` - - Make `PreToolUse` synchronous by removing `"async": true`. - -- Modify `memind-integrations/claude-code/settings.json` - - Add default PreToolUse context settings. - -- Modify `memind-integrations/claude-code/scripts/lib/config.py` - - Add config defaults and env vars for PreToolUse context. - -- Modify `memind-integrations/claude-code/scripts/lib/client.py` - - Extend the local `retrieve(...)` wrapper to pass structured filters and include options. - -- Create `memind-integrations/claude-code/scripts/lib/tool_context.py` - - Extract the tool target, query Memind, rank hits, and return compiler input. - -- Modify `memind-integrations/claude-code/scripts/lib/context_compiler.py` - - Add `compile_tool_context(...)`. - -- Modify `memind-integrations/claude-code/scripts/pre_tool_use.py` - - Buffer the event first, then optionally inject compiled tool context. - -- Modify Claude Code tests: - - `memind-integrations/claude-code/tests/test_config.py` - - `memind-integrations/claude-code/tests/test_client.py` - - `memind-integrations/claude-code/tests/test_context_compiler.py` - - `memind-integrations/claude-code/tests/test_hooks.py` - - `memind-integrations/claude-code/tests/test_manifest.py` - - `memind-integrations/claude-code/tests/test_installer.py` - -- Apply Codex-specific changes with the Codex source client, plugin root, state key, and installer layout: - - `memind-integrations/codex/settings.json` - - `memind-integrations/codex/scripts/lib/config.py` - - `memind-integrations/codex/scripts/lib/client.py` - - `memind-integrations/codex/scripts/lib/tool_context.py` - - `memind-integrations/codex/scripts/lib/context_compiler.py` - - `memind-integrations/codex/scripts/pre_tool_use.py` - - `memind-integrations/codex/tests/*` - - `memind-integrations/codex/install.sh` - -- Modify documentation: - - `memind-integrations/claude-code/README.md` - - `memind-integrations/codex/README.md` - - `docs/superpowers/specs/2026-05-24-rawdata-agent-design.md` - ---- - -### Task 1: Add PreToolUse Context Configuration And Manifest Contract - -**Files:** -- Modify: `memind-integrations/claude-code/settings.json` -- Modify: `memind-integrations/claude-code/scripts/lib/config.py` -- Modify: `memind-integrations/claude-code/hooks/hooks.json` -- Modify: `memind-integrations/claude-code/tests/test_config.py` -- Modify: `memind-integrations/claude-code/tests/test_manifest.py` -- Modify: `memind-integrations/codex/settings.json` -- Modify: `memind-integrations/codex/scripts/lib/config.py` -- Modify: `memind-integrations/codex/tests/test_config.py` -- Modify: `memind-integrations/codex/tests/test_manifest.py` - -- [ ] **Step 1: Add failing Claude Code config assertions** - -Add these assertions to `test_default_settings` in `memind-integrations/claude-code/tests/test_config.py`: - -```python -self.assertTrue(DEFAULT_SETTINGS["autoToolContext"]) -self.assertEqual(DEFAULT_SETTINGS["toolContextMaxChars"], 3500) -self.assertEqual(DEFAULT_SETTINGS["toolContextEntryMaxChars"], 520) -self.assertEqual(DEFAULT_SETTINGS["toolContextMaxItems"], 6) -self.assertEqual(DEFAULT_SETTINGS["toolContextMinExactItems"], 2) -``` - -Add this env override test: - -```python -def test_tool_context_env_overrides(self): - config = load_config( - plugin_root=ROOT, - user_config_path=Path("/no/such/file"), - env={ - "CLAUDE_PLUGIN_ROOT": str(ROOT), - "MEMIND_AUTO_TOOL_CONTEXT": "false", - "MEMIND_TOOL_CONTEXT_MAX_CHARS": "2500", - "MEMIND_TOOL_CONTEXT_ENTRY_MAX_CHARS": "400", - "MEMIND_TOOL_CONTEXT_MAX_ITEMS": "4", - "MEMIND_TOOL_CONTEXT_MIN_EXACT_ITEMS": "1", - }, - ) - - self.assertFalse(config["autoToolContext"]) - self.assertEqual(config["toolContextMaxChars"], 2500) - self.assertEqual(config["toolContextEntryMaxChars"], 400) - self.assertEqual(config["toolContextMaxItems"], 4) - self.assertEqual(config["toolContextMinExactItems"], 1) -``` - -- [ ] **Step 2: Add failing Claude Code manifest assertions** - -In `memind-integrations/claude-code/tests/test_manifest.py`, change the PreToolUse assertion from async to synchronous: - -```python -pre_tool_hook = hooks["PreToolUse"][0]["hooks"][0] -self.assertNotIn("async", pre_tool_hook) -self.assertLessEqual(pre_tool_hook["timeout"], 5) -self.assertTrue(hooks["PostToolUse"][0]["hooks"][0]["async"]) -``` - -Keep the existing assertions for `PostToolUse`, `Notification`, `SubagentStop`, and `Stop` async behavior. - -- [ ] **Step 3: Add Codex config assertions** - -Add these default assertions to `memind-integrations/codex/tests/test_config.py`: - -```python -self.assertTrue(DEFAULT_SETTINGS["autoToolContext"]) -self.assertEqual(DEFAULT_SETTINGS["toolContextMaxChars"], 3500) -self.assertEqual(DEFAULT_SETTINGS["toolContextEntryMaxChars"], 520) -self.assertEqual(DEFAULT_SETTINGS["toolContextMaxItems"], 6) -self.assertEqual(DEFAULT_SETTINGS["toolContextMinExactItems"], 2) -``` - -Add this Codex env override test, using `CODEX_PLUGIN_ROOT`: - -```python -def test_tool_context_env_overrides(self): - config = load_config( - plugin_root=ROOT, - user_config_path=Path("/no/such/file"), - env={ - "CODEX_PLUGIN_ROOT": str(ROOT), - "MEMIND_AUTO_TOOL_CONTEXT": "false", - "MEMIND_TOOL_CONTEXT_MAX_CHARS": "2500", - "MEMIND_TOOL_CONTEXT_ENTRY_MAX_CHARS": "400", - "MEMIND_TOOL_CONTEXT_MAX_ITEMS": "4", - "MEMIND_TOOL_CONTEXT_MIN_EXACT_ITEMS": "1", - }, - ) - - self.assertFalse(config["autoToolContext"]) - self.assertEqual(config["toolContextMaxChars"], 2500) - self.assertEqual(config["toolContextEntryMaxChars"], 400) - self.assertEqual(config["toolContextMaxItems"], 4) - self.assertEqual(config["toolContextMinExactItems"], 1) -``` - -- [ ] **Step 4: Keep Codex manifest synchronous** - -In `memind-integrations/codex/tests/test_manifest.py`, add an explicit assertion: - -```python -self.assertNotIn("async", hooks["PreToolUse"][0]["hooks"][0]) -``` - -- [ ] **Step 5: Run config and manifest tests to verify failure** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_config.py \ - memind-integrations/claude-code/tests/test_manifest.py \ - memind-integrations/codex/tests/test_config.py \ - memind-integrations/codex/tests/test_manifest.py -``` - -Expected: FAIL because the new config keys are missing and Claude Code PreToolUse is still async. - -- [ ] **Step 6: Add settings defaults** - -Add these keys to both `settings.json` files: - -```json -"autoToolContext": true, -"toolContextMaxChars": 3500, -"toolContextEntryMaxChars": 520, -"toolContextMaxItems": 6, -"toolContextMinExactItems": 2, -``` - -Place them near `retrieveContextTurns` so all retrieval-related settings stay together. - -- [ ] **Step 7: Add config defaults and env vars** - -In both `scripts/lib/config.py` files, add to `DEFAULT_SETTINGS`: - -```python -"autoToolContext": True, -"toolContextMaxChars": 3500, -"toolContextEntryMaxChars": 520, -"toolContextMaxItems": 6, -"toolContextMinExactItems": 2, -``` - -Add to `ENV_MAP` in both files: - -```python -"MEMIND_AUTO_TOOL_CONTEXT": ("autoToolContext", "bool"), -"MEMIND_TOOL_CONTEXT_MAX_CHARS": ("toolContextMaxChars", "int"), -"MEMIND_TOOL_CONTEXT_ENTRY_MAX_CHARS": ("toolContextEntryMaxChars", "int"), -"MEMIND_TOOL_CONTEXT_MAX_ITEMS": ("toolContextMaxItems", "int"), -"MEMIND_TOOL_CONTEXT_MIN_EXACT_ITEMS": ("toolContextMinExactItems", "int_allow_zero"), -``` - -- [ ] **Step 8: Make Claude Code PreToolUse synchronous** - -In `memind-integrations/claude-code/hooks/hooks.json`, remove only this line from the `PreToolUse` hook: - -```json -"async": true -``` - -Do not change `PostToolUse`, `Stop`, `Notification`, or `SubagentStop`. - -- [ ] **Step 9: Run tests and verify pass** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_config.py \ - memind-integrations/claude-code/tests/test_manifest.py \ - memind-integrations/codex/tests/test_config.py \ - memind-integrations/codex/tests/test_manifest.py -``` - -Expected: PASS. - -- [ ] **Step 10: Commit** - -```bash -git add \ - memind-integrations/claude-code/settings.json \ - memind-integrations/claude-code/scripts/lib/config.py \ - memind-integrations/claude-code/hooks/hooks.json \ - memind-integrations/claude-code/tests/test_config.py \ - memind-integrations/claude-code/tests/test_manifest.py \ - memind-integrations/codex/settings.json \ - memind-integrations/codex/scripts/lib/config.py \ - memind-integrations/codex/tests/test_config.py \ - memind-integrations/codex/tests/test_manifest.py -git commit -m "feat(agent): configure pre-tool context" -``` - ---- - -### Task 2: Extend Local Client Wrappers For Structured Retrieval - -**Files:** -- Modify: `memind-integrations/claude-code/scripts/lib/client.py` -- Modify: `memind-integrations/claude-code/tests/test_client.py` -- Modify: `memind-integrations/codex/scripts/lib/client.py` -- Modify: `memind-integrations/codex/tests/test_client.py` - -- [ ] **Step 1: Add failing Claude Code structured retrieve wrapper test** - -In `memind-integrations/claude-code/tests/test_client.py`, first extend the fake model helpers near `_MetadataFilter`: - -```python -class _MetadataFilter: - def __init__(self, all=None, any=None, not_=None, **kwargs): - excluded = kwargs.get("not", not_) - self.all = [_MetadataCondition(**item) for item in (all or [])] - self.any = [_MetadataCondition(**item) for item in (any or [])] - self.not_ = [_MetadataCondition(**item) for item in (excluded or [])] - - -class _RetrieveIncludeOptions: - def __init__( - self, - raw_data_metadata=None, - rawDataMetadata=None, - raw_data_segment=None, - rawDataSegment=None, - ): - self.raw_data_metadata = ( - raw_data_metadata if raw_data_metadata is not None else rawDataMetadata - ) - self.raw_data_segment = ( - raw_data_segment if raw_data_segment is not None else rawDataSegment - ) - - -class _TimeRange: - def __init__(self, field=None, from_=None, to=None, **kwargs): - self.field = field - self.from_ = kwargs.get("from", from_) - self.to = to -``` - -Then add these exports to `_fake_memind_module()`: - -```python -module.MetadataFilter = _MetadataFilter -module.RetrieveIncludeOptions = _RetrieveIncludeOptions -module.TimeRange = _TimeRange -``` - -Add this test method to `ClientTest`: - -```python -def test_retrieve_passes_structured_filters(self): - with mock.patch.dict(sys.modules, {"memind": _fake_memind_module()}): - MemindClient = _load_client_class() - client = MemindClient("http://memind", "token", timeout=1, max_retries=0) - result = client.retrieve( - "u", - "a", - "payment context", - "SIMPLE", - False, - scope="AGENT", - categories=["resolution", "tool"], - metadata_filter={ - "all": [{"path": "projectSlug", "op": "eq", "value": "payment"}], - "any": [{"path": "files", "op": "contains", "value": "src/payment/calc.ts"}], - }, - include={"rawDataMetadata": True}, - ) - - self.assertIsNotNone(result) - instance = _FakeSyncMemindClient.instances[0] - retrieve_call = instance.memory.calls[0][1] - self.assertEqual(retrieve_call["scope"], "AGENT") - self.assertEqual(retrieve_call["categories"], ["resolution", "tool"]) - self.assertEqual(retrieve_call["metadata_filter"].all[0].path, "projectSlug") - self.assertEqual(retrieve_call["metadata_filter"].any[0].path, "files") - self.assertTrue(retrieve_call["include"].raw_data_metadata) -``` - -- [ ] **Step 2: Add Codex structured retrieve wrapper test** - -In `memind-integrations/codex/tests/test_client.py`, add these fake model helpers because the Codex wrapper imports -official `memind` Python client types: - -```python -class _MetadataFilter: - def __init__(self, all=None, any=None, not_=None, **kwargs): - excluded = kwargs.get("not", not_) - self.all = [_MetadataCondition(**item) for item in (all or [])] - self.any = [_MetadataCondition(**item) for item in (any or [])] - self.not_ = [_MetadataCondition(**item) for item in (excluded or [])] - - -class _RetrieveIncludeOptions: - def __init__( - self, - raw_data_metadata=None, - rawDataMetadata=None, - raw_data_segment=None, - rawDataSegment=None, - ): - self.raw_data_metadata = ( - raw_data_metadata if raw_data_metadata is not None else rawDataMetadata - ) - self.raw_data_segment = ( - raw_data_segment if raw_data_segment is not None else rawDataSegment - ) - - -class _TimeRange: - def __init__(self, field=None, from_=None, to=None, **kwargs): - self.field = field - self.from_ = kwargs.get("from", from_) - self.to = to -``` - -Export them from the Codex `_fake_memind_module()`: - -```python -module.MetadataFilter = _MetadataFilter -module.RetrieveIncludeOptions = _RetrieveIncludeOptions -module.TimeRange = _TimeRange -``` - -Add this test method to the Codex client test class: - -```python -def test_retrieve_passes_structured_filters(self): - with mock.patch.dict(sys.modules, {"memind": _fake_memind_module()}): - MemindClient = _load_client_class() - client = MemindClient("http://memind", "token", timeout=1, max_retries=0) - result = client.retrieve( - "u", - "a", - "payment context", - "SIMPLE", - False, - scope="AGENT", - categories=["resolution", "tool"], - metadata_filter={ - "all": [{"path": "projectSlug", "op": "eq", "value": "payment"}], - "any": [{"path": "files", "op": "contains", "value": "src/payment/calc.ts"}], - }, - include={"rawDataMetadata": True}, - ) - - self.assertIsNotNone(result) - instance = _FakeSyncMemindClient.instances[0] - retrieve_call = instance.memory.calls[0][1] - self.assertEqual(retrieve_call["scope"], "AGENT") - self.assertEqual(retrieve_call["categories"], ["resolution", "tool"]) - self.assertEqual(retrieve_call["metadata_filter"].all[0].path, "projectSlug") - self.assertEqual(retrieve_call["metadata_filter"].any[0].path, "files") - self.assertTrue(retrieve_call["include"].raw_data_metadata) -``` - -- [ ] **Step 3: Run tests to verify failure** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_client.py \ - memind-integrations/codex/tests/test_client.py -``` - -Expected: FAIL because the local wrapper does not accept structured retrieve parameters yet. - -- [ ] **Step 4: Extend the Claude Code wrapper** - -Change the `retrieve` signature in `memind-integrations/claude-code/scripts/lib/client.py` to: - -```python -def retrieve( - self, - user_id, - agent_id, - query, - strategy="SIMPLE", - trace=False, - scope=None, - categories=None, - time_range=None, - metadata_filter=None, - include=None, -): -``` - -Inside the method import these types: - -```python -from memind import ( - MemindClient as OfficialMemindClient, - MetadataFilter, - RetrieveIncludeOptions, - TimeRange, -) -``` - -Build typed optional objects: - -```python -metadata_filter_obj = ( - MetadataFilter(**metadata_filter) - if isinstance(metadata_filter, dict) - else metadata_filter -) -include_obj = ( - RetrieveIncludeOptions(**include) - if isinstance(include, dict) - else include -) -time_range_obj = TimeRange(**time_range) if isinstance(time_range, dict) else time_range -``` - -Pass them to the official client: - -```python -return client.memory.retrieve( - user_id=user_id, - agent_id=agent_id, - query=query, - strategy=strategy, - trace=trace, - scope=scope, - categories=categories, - time_range=time_range_obj, - metadata_filter=metadata_filter_obj, - include=include_obj, -) -``` - -- [ ] **Step 5: Extend the Codex wrapper** - -Change the `retrieve` signature in `memind-integrations/codex/scripts/lib/client.py` to accept structured retrieval -parameters: - -```python -def retrieve( - self, - user_id, - agent_id, - query, - strategy="SIMPLE", - trace=False, - scope=None, - categories=None, - time_range=None, - metadata_filter=None, - include=None, -): -``` - -Inside the method import these official client model types: - -```python -from memind import ( - MemindClient as OfficialMemindClient, - MetadataFilter, - RetrieveIncludeOptions, - TimeRange, -) -``` - -Convert dict inputs to official typed objects: - -```python -metadata_filter_obj = ( - MetadataFilter(**metadata_filter) - if isinstance(metadata_filter, dict) - else metadata_filter -) -include_obj = ( - RetrieveIncludeOptions(**include) - if isinstance(include, dict) - else include -) -time_range_obj = TimeRange(**time_range) if isinstance(time_range, dict) else time_range -``` - -Pass all optional retrieval controls through to `client.memory.retrieve(...)`: - -```python -return client.memory.retrieve( - user_id=user_id, - agent_id=agent_id, - query=query, - strategy=strategy, - trace=trace, - scope=scope, - categories=categories, - time_range=time_range_obj, - metadata_filter=metadata_filter_obj, - include=include_obj, -) -``` - -- [ ] **Step 6: Run client tests and verify pass** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_client.py \ - memind-integrations/codex/tests/test_client.py -``` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add \ - memind-integrations/claude-code/scripts/lib/client.py \ - memind-integrations/claude-code/tests/test_client.py \ - memind-integrations/codex/scripts/lib/client.py \ - memind-integrations/codex/tests/test_client.py -git commit -m "feat(agent): support structured retrieve in integrations" -``` - ---- - -### Task 3: Add Tool Context Target Extraction And Query Planning - -**Files:** -- Create: `memind-integrations/claude-code/scripts/lib/tool_context.py` -- Create: `memind-integrations/claude-code/tests/test_tool_context.py` -- Create: `memind-integrations/codex/scripts/lib/tool_context.py` -- Create: `memind-integrations/codex/tests/test_tool_context.py` - -- [ ] **Step 1: Add failing Claude Code target extraction tests** - -Create `memind-integrations/claude-code/tests/test_tool_context.py`: - -```python -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -import sys -import unittest -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT / "scripts")) - -from scripts.lib.tool_context import ( - build_metadata_filter, - current_turn_prompt, - extract_tool_context_target, - should_query_tool_context, -) - - -class ToolContextTest(unittest.TestCase): - def test_extracts_file_edit_target(self): - target = extract_tool_context_target( - { - "kind": "file_edit", - "toolName": "Edit", - "path": "src/payment/calc.ts", - "operation": "edit", - "metadata": {"turnId": "s-turn-1"}, - }, - {"cwd": "/repo/payment"}, - "payment-service-abc", - ) - - self.assertEqual(target["toolName"], "Edit") - self.assertEqual(target["kind"], "file_edit") - self.assertEqual(target["path"], "src/payment/calc.ts") - self.assertEqual(target["projectSlug"], "payment-service-abc") - - def test_extracts_command_target(self): - target = extract_tool_context_target( - { - "kind": "test_result", - "toolName": "Bash", - "command": "npm test payment", - "operation": "run", - "metadata": {"validationType": "test"}, - }, - {"cwd": "/repo/payment"}, - "payment-service-abc", - ) - - self.assertEqual(target["command"], "npm test payment") - self.assertEqual(target["validationType"], "test") - - def test_skips_low_value_tools(self): - target = extract_tool_context_target( - {"kind": "file_read", "toolName": "Read", "path": "README.md"}, - {"cwd": "/repo/payment"}, - "payment-service-abc", - ) - - self.assertFalse(should_query_tool_context(target, {"autoToolContext": True})) - - def test_skips_when_disabled_or_no_target(self): - self.assertFalse(should_query_tool_context({}, {"autoToolContext": True})) - self.assertFalse( - should_query_tool_context( - {"kind": "file_edit", "path": "src/a.ts"}, {"autoToolContext": False} - ) - ) - - def test_builds_top_level_metadata_filter(self): - metadata_filter = build_metadata_filter( - { - "projectSlug": "payment-service-abc", - "path": "src/payment/calc.ts", - "command": "npm test payment", - "toolName": "Bash", - }, - include_project=True, - ) - - self.assertEqual( - metadata_filter["all"], - [{"path": "projectSlug", "op": "eq", "value": "payment-service-abc"}], - ) - self.assertIn( - {"path": "files", "op": "contains", "value": "src/payment/calc.ts"}, - metadata_filter["any"], - ) - self.assertIn( - {"path": "commands", "op": "contains", "value": "npm test payment"}, - metadata_filter["any"], - ) - self.assertIn( - {"path": "toolNames", "op": "contains", "value": "Bash"}, - metadata_filter["any"], - ) - - def test_current_turn_prompt_uses_matching_turn_id(self): - prompt = current_turn_prompt( - [ - {"kind": "user_prompt", "text": "older", "metadata": {"turnId": "t0"}}, - {"kind": "user_prompt", "text": "Fix payment tests", "metadata": {"turnId": "t1"}}, - {"kind": "file_edit", "metadata": {"turnId": "t1"}}, - ], - "t1", - ) - - self.assertEqual(prompt, "Fix payment tests") - - -if __name__ == "__main__": - unittest.main() -``` - -- [ ] **Step 2: Add failing Codex target extraction tests** - -Create `memind-integrations/codex/tests/test_tool_context.py` with the Codex scripts path and these expected normalized -target behaviors: - -```python -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -import sys -import unittest -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT / "scripts")) - -from scripts.lib.tool_context import ( - build_metadata_filter, - current_turn_prompt, - extract_tool_context_target, - should_query_tool_context, -) - - -class ToolContextTest(unittest.TestCase): - def test_extracts_file_edit_target(self): - target = extract_tool_context_target( - { - "kind": "file_edit", - "toolName": "Edit", - "path": "src/payment/calc.ts", - "operation": "edit", - "metadata": {"turnId": "s-turn-1"}, - }, - {"cwd": "/repo/payment"}, - "payment-service-abc", - ) - - self.assertEqual(target["toolName"], "Edit") - self.assertEqual(target["kind"], "file_edit") - self.assertEqual(target["path"], "src/payment/calc.ts") - self.assertEqual(target["projectSlug"], "payment-service-abc") - - def test_extracts_command_target(self): - target = extract_tool_context_target( - { - "kind": "test_result", - "toolName": "Bash", - "command": "npm test payment", - "operation": "run", - "metadata": {"validationType": "test"}, - }, - {"cwd": "/repo/payment"}, - "payment-service-abc", - ) - - self.assertEqual(target["command"], "npm test payment") - self.assertEqual(target["validationType"], "test") - - def test_skips_low_value_tools(self): - target = extract_tool_context_target( - {"kind": "file_read", "toolName": "Read", "path": "README.md"}, - {"cwd": "/repo/payment"}, - "payment-service-abc", - ) - - self.assertFalse(should_query_tool_context(target, {"autoToolContext": True})) - - def test_skips_when_disabled_or_no_target(self): - self.assertFalse(should_query_tool_context({}, {"autoToolContext": True})) - self.assertFalse( - should_query_tool_context( - {"kind": "file_edit", "path": "src/a.ts"}, {"autoToolContext": False} - ) - ) - - def test_builds_top_level_metadata_filter(self): - metadata_filter = build_metadata_filter( - { - "projectSlug": "payment-service-abc", - "path": "src/payment/calc.ts", - "command": "npm test payment", - "toolName": "Bash", - }, - include_project=True, - ) - - self.assertEqual( - metadata_filter["all"], - [{"path": "projectSlug", "op": "eq", "value": "payment-service-abc"}], - ) - self.assertIn( - {"path": "files", "op": "contains", "value": "src/payment/calc.ts"}, - metadata_filter["any"], - ) - self.assertIn( - {"path": "commands", "op": "contains", "value": "npm test payment"}, - metadata_filter["any"], - ) - self.assertIn( - {"path": "toolNames", "op": "contains", "value": "Bash"}, - metadata_filter["any"], - ) - - def test_current_turn_prompt_uses_matching_turn_id(self): - prompt = current_turn_prompt( - [ - {"kind": "user_prompt", "text": "older", "metadata": {"turnId": "t0"}}, - {"kind": "user_prompt", "text": "Fix payment tests", "metadata": {"turnId": "t1"}}, - {"kind": "file_edit", "metadata": {"turnId": "t1"}}, - ], - "t1", - ) - - self.assertEqual(prompt, "Fix payment tests") - - -if __name__ == "__main__": - unittest.main() -``` - -- [ ] **Step 3: Run tests to verify failure** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_tool_context.py \ - memind-integrations/codex/tests/test_tool_context.py -``` - -Expected: FAIL because `scripts/lib/tool_context.py` does not exist. - -- [ ] **Step 4: Implement Claude Code target extraction** - -Create `memind-integrations/claude-code/scripts/lib/tool_context.py`: - -```python -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -HIGH_VALUE_KINDS = {"file_edit", "command", "test_result"} - - -def extract_tool_context_target(event, hook_input, project_slug): - metadata = event.get("metadata") or {} - target = { - "toolName": event.get("toolName"), - "kind": event.get("kind"), - "path": event.get("path"), - "command": event.get("command"), - "operation": event.get("operation"), - "validationType": metadata.get("validationType"), - "projectSlug": project_slug, - "cwd": hook_input.get("cwd"), - "turnId": metadata.get("turnId"), - "turnSeq": metadata.get("turnSeq"), - } - return {key: value for key, value in target.items() if value not in (None, "", [])} - - -def should_query_tool_context(target, config): - if not config.get("autoToolContext", True) or not config.get("autoRetrieve", True): - return False - if not target or target.get("kind") not in HIGH_VALUE_KINDS: - return False - return bool(target.get("path") or target.get("command")) - - -def build_metadata_filter(target, include_project=True): - all_conditions = [] - any_conditions = [] - if include_project and target.get("projectSlug"): - all_conditions.append( - {"path": "projectSlug", "op": "eq", "value": target["projectSlug"]} - ) - if target.get("path"): - any_conditions.append({"path": "files", "op": "contains", "value": target["path"]}) - if target.get("command"): - any_conditions.append( - {"path": "commands", "op": "contains", "value": target["command"]} - ) - if target.get("toolName"): - any_conditions.append( - {"path": "toolNames", "op": "contains", "value": target["toolName"]} - ) - return { - "all": all_conditions, - "any": any_conditions, - "not": [], - } - - -def current_turn_prompt(events, turn_id): - if not turn_id: - return "" - for event in reversed(events or []): - metadata = event.get("metadata") or {} - if event.get("kind") == "user_prompt" and metadata.get("turnId") == turn_id: - return event.get("text") or "" - return "" -``` - -- [ ] **Step 5: Implement Codex target extraction** - -Create `memind-integrations/codex/scripts/lib/tool_context.py` with the integration-neutral target schema below, so -downstream retrieval and compilation receive consistent fields across Codex and Claude Code: - -```python -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -HIGH_VALUE_KINDS = {"file_edit", "command", "test_result"} - - -def extract_tool_context_target(event, hook_input, project_slug): - metadata = event.get("metadata") or {} - target = { - "toolName": event.get("toolName"), - "kind": event.get("kind"), - "path": event.get("path"), - "command": event.get("command"), - "operation": event.get("operation"), - "validationType": metadata.get("validationType"), - "projectSlug": project_slug, - "cwd": hook_input.get("cwd"), - "turnId": metadata.get("turnId"), - "turnSeq": metadata.get("turnSeq"), - } - return {key: value for key, value in target.items() if value not in (None, "", [])} - - -def should_query_tool_context(target, config): - if not config.get("autoToolContext", True) or not config.get("autoRetrieve", True): - return False - if not target or target.get("kind") not in HIGH_VALUE_KINDS: - return False - return bool(target.get("path") or target.get("command")) - - -def build_metadata_filter(target, include_project=True): - all_conditions = [] - any_conditions = [] - if include_project and target.get("projectSlug"): - all_conditions.append( - {"path": "projectSlug", "op": "eq", "value": target["projectSlug"]} - ) - if target.get("path"): - any_conditions.append({"path": "files", "op": "contains", "value": target["path"]}) - if target.get("command"): - any_conditions.append( - {"path": "commands", "op": "contains", "value": target["command"]} - ) - if target.get("toolName"): - any_conditions.append( - {"path": "toolNames", "op": "contains", "value": target["toolName"]} - ) - return { - "all": all_conditions, - "any": any_conditions, - "not": [], - } - - -def current_turn_prompt(events, turn_id): - if not turn_id: - return "" - for event in reversed(events or []): - metadata = event.get("metadata") or {} - if event.get("kind") == "user_prompt" and metadata.get("turnId") == turn_id: - return event.get("text") or "" - return "" -``` - -- [ ] **Step 6: Run tests and verify pass** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_tool_context.py \ - memind-integrations/codex/tests/test_tool_context.py -``` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add \ - memind-integrations/claude-code/scripts/lib/tool_context.py \ - memind-integrations/claude-code/tests/test_tool_context.py \ - memind-integrations/codex/scripts/lib/tool_context.py \ - memind-integrations/codex/tests/test_tool_context.py -git commit -m "feat(agent): extract pre-tool context targets" -``` - ---- - -### Task 4: Add Tool Context Retrieval And Ranking - -**Files:** -- Modify: `memind-integrations/claude-code/scripts/lib/tool_context.py` -- Modify: `memind-integrations/claude-code/tests/test_tool_context.py` -- Modify: `memind-integrations/codex/scripts/lib/tool_context.py` -- Modify: `memind-integrations/codex/tests/test_tool_context.py` - -- [ ] **Step 1: Add failing retrieval test for exact item and rawdata queries** - -In both `test_tool_context.py` files, add `SimpleNamespace` to the imports: - -```python -from types import SimpleNamespace -``` - -Then add this fake client class above `ToolContextTest`: - -```python -class FakeClient: - def __init__(self): - self.item_queries = [] - self.raw_queries = [] - self.retrieve_queries = [] - - def query_items(self, **kwargs): - self.item_queries.append(kwargs) - if kwargs.get("metadata_filter", {}).get("all"): - return SimpleNamespace( - items=[ - SimpleNamespace( - id="res-1", - text="rounding mismatch was resolved in src/payment/calc.ts and validated with npm test payment.", - category="resolution", - created_at="2026-05-27T10:00:00Z", - metadata={ - "projectSlug": "payment-service-abc", - "files": ["src/payment/calc.ts"], - "commands": ["npm test payment"], - }, - ) - ] - ) - return SimpleNamespace(items=[]) - - def query_raw_data(self, **kwargs): - self.raw_queries.append(kwargs) - return SimpleNamespace( - raw_data=[ - SimpleNamespace( - id="rd-1", - caption="Edited src/payment/calc.ts and validated npm test payment.", - type="agent_timeline", - created_at="2026-05-27T10:05:00Z", - metadata={ - "projectSlug": "payment-service-abc", - "files": ["src/payment/calc.ts"], - "commands": ["npm test payment"], - "toolStats": {"Bash": {"successCount": 1, "failCount": 1}}, - }, - ) - ] - ) - - def retrieve(self, *args, **kwargs): - self.retrieve_queries.append(kwargs) - return SimpleNamespace(items=[], insights=[], raw_data=[]) -``` - -Add this method inside `ToolContextTest`: - -```python - -def test_load_tool_context_uses_exact_queries_first(self): - from scripts.lib.tool_context import load_tool_context - - client = FakeClient() - context = load_tool_context( - client, - "u", - "a", - { - "toolName": "Edit", - "kind": "file_edit", - "path": "src/payment/calc.ts", - "projectSlug": "payment-service-abc", - "prompt": "Fix payment tests", - }, - {"toolContextMaxItems": 6, "toolContextMinExactItems": 2}, - ) - - self.assertEqual(len(client.item_queries), 2) - self.assertEqual(len(client.raw_queries), 1) - self.assertEqual(client.item_queries[0]["categories"], ["resolution", "tool", "playbook", "directive"]) - self.assertEqual(client.item_queries[0]["metadata_filter"]["all"][0]["path"], "projectSlug") - self.assertEqual(client.raw_queries[0]["types"], ["agent_timeline"]) - self.assertEqual(context["target"]["path"], "src/payment/calc.ts") - self.assertEqual(context["items"][0]["category"], "resolution") - self.assertEqual(context["rawData"][0]["id"], "rd-1") -``` - -- [ ] **Step 2: Add failing semantic fallback test** - -Add this method inside `ToolContextTest` in both `test_tool_context.py` files: - -```python -def test_load_tool_context_uses_retrieve_fallback_when_exact_hits_are_sparse(self): - from scripts.lib.tool_context import load_tool_context - - class SparseClient(FakeClient): - def query_items(self, **kwargs): - self.item_queries.append(kwargs) - return SimpleNamespace(items=[]) - - def query_raw_data(self, **kwargs): - self.raw_queries.append(kwargs) - return SimpleNamespace(raw_data=[]) - - def retrieve(self, *args, **kwargs): - self.retrieve_queries.append(kwargs) - return SimpleNamespace( - items=[ - SimpleNamespace( - id="tool-1", - text="Use npm test payment after editing payment calculation files.", - category="tool", - created_at="2026-05-27T10:00:00Z", - metadata={"commands": ["npm test payment"]}, - ) - ], - insights=[], - raw_data=[], - ) - - client = SparseClient() - context = load_tool_context( - client, - "u", - "a", - { - "toolName": "Bash", - "kind": "test_result", - "command": "npm test payment", - "projectSlug": "payment-service-abc", - "prompt": "Fix payment tests", - }, - {"toolContextMaxItems": 6, "toolContextMinExactItems": 1}, - ) - - self.assertEqual(len(client.retrieve_queries), 1) - self.assertEqual(client.retrieve_queries[0]["categories"], ["resolution", "tool", "playbook", "directive"]) - self.assertEqual(context["items"][0]["id"], "tool-1") -``` - -- [ ] **Step 3: Run tests to verify failure** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_tool_context.py \ - memind-integrations/codex/tests/test_tool_context.py -``` - -Expected: FAIL because `load_tool_context` does not exist. - -- [ ] **Step 4: Implement query loading in Claude Code** - -Add these functions to `memind-integrations/claude-code/scripts/lib/tool_context.py`: - -```python -TOOL_CONTEXT_CATEGORIES = ["resolution", "tool", "playbook", "directive"] - - -def load_tool_context(client, user_id, agent_id, target, config): - max_items = int(config.get("toolContextMaxItems", 6)) - min_exact = int(config.get("toolContextMinExactItems", 2)) - exact_items = [] - - for include_project in [True, False]: - metadata_filter = build_metadata_filter(target, include_project=include_project) - if not metadata_filter["any"]: - continue - response = client.query_items( - user_id=user_id, - agent_id=agent_id, - scope=None, - categories=TOOL_CONTEXT_CATEGORIES, - source_clients=None, - raw_data_types=["agent_timeline"], - metadata_filter=metadata_filter, - limit=max(10, max_items * 3), - ) - exact_items.extend(_normalize_items(getattr(response, "items", []) or [])) - if len(exact_items) >= max_items: - break - - raw_data = [] - metadata_filter = build_metadata_filter(target, include_project=bool(target.get("projectSlug"))) - if metadata_filter["any"]: - response = client.query_raw_data( - user_id=user_id, - agent_id=agent_id, - types=["agent_timeline"], - source_clients=None, - metadata_filter=metadata_filter, - include={"metadata": True, "segment": False}, - limit=max(6, max_items), - ) - raw_data = _normalize_raw_data(getattr(response, "raw_data", []) or []) - - fallback_items = [] - if len(exact_items) < min_exact: - retrieve_response = client.retrieve( - user_id, - agent_id, - _semantic_query(target), - config.get("retrieveStrategy", "SIMPLE"), - False, - scope=None, - categories=TOOL_CONTEXT_CATEGORIES, - metadata_filter=( - {"all": [{"path": "projectSlug", "op": "eq", "value": target["projectSlug"]}]} - if target.get("projectSlug") - else None - ), - include={"rawDataMetadata": True}, - ) - fallback_items = _normalize_items(getattr(retrieve_response, "items", []) or []) - - items = _dedupe_by_id(exact_items + fallback_items) - return { - "target": dict(target), - "items": rank_items(items, target)[:max_items], - "rawData": rank_raw_data(raw_data, target)[:max_items], - } - - -def _semantic_query(target): - parts = [] - if target.get("prompt"): - parts.append("task: " + target["prompt"]) - if target.get("path"): - parts.append("file: " + target["path"]) - if target.get("command"): - parts.append("command: " + target["command"]) - if target.get("toolName"): - parts.append("tool: " + target["toolName"]) - return "\n".join(parts) or "coding agent tool context" - - -def _normalize_items(items): - result = [] - for item in items: - result.append( - { - "id": _field(item, "id"), - "text": _field(item, "text"), - "category": str(_field(item, "category") or "memory").lower(), - "createdAt": _field(item, "createdAt") or _field(item, "created_at"), - "metadata": _field(item, "metadata") or {}, - } - ) - return [item for item in result if item.get("text")] - - -def _normalize_raw_data(raw_data): - result = [] - for raw in raw_data: - result.append( - { - "id": _field(raw, "rawDataId") or _field(raw, "raw_data_id") or _field(raw, "id"), - "caption": _field(raw, "caption"), - "type": _field(raw, "type"), - "createdAt": _field(raw, "createdAt") or _field(raw, "created_at"), - "metadata": _field(raw, "metadata") or {}, - } - ) - return [raw for raw in result if raw.get("caption") or raw.get("metadata")] - - -def rank_items(items, target): - return sorted( - items, - key=lambda item: ( - _match_score(item.get("metadata") or {}, target), - _category_score(item.get("category")), - item.get("createdAt") or "", - item.get("id") or "", - ), - reverse=True, - ) - - -def rank_raw_data(raw_data, target): - return sorted( - raw_data, - key=lambda raw: ( - _match_score(raw.get("metadata") or {}, target), - raw.get("createdAt") or "", - raw.get("id") or "", - ), - reverse=True, - ) - - -def _match_score(metadata, target): - score = 0 - if target.get("projectSlug") and metadata.get("projectSlug") == target["projectSlug"]: - score += 3 - if target.get("path") and target["path"] in metadata.get("files", []): - score += 10 - if target.get("command") and target["command"] in metadata.get("commands", []): - score += 8 - if target.get("toolName") and target["toolName"] in metadata.get("toolNames", []): - score += 3 - stats = metadata.get("toolStats") or {} - if target.get("toolName") in stats: - tool_stats = stats[target["toolName"]] - score += int(tool_stats.get("successCount") or 0) - return score - - -def _category_score(category): - return {"resolution": 5, "tool": 4, "playbook": 3, "directive": 2}.get(category or "", 1) - - -def _dedupe_by_id(items): - result = [] - seen = set() - for item in items: - key = item.get("id") or item.get("text") - if key in seen: - continue - seen.add(key) - result.append(item) - return result - - -def _field(value, name): - if isinstance(value, dict): - return value.get(name) - return getattr(value, name, None) -``` - -- [ ] **Step 5: Implement Codex query loading** - -Add the retrieval, normalization, ranking, and helper functions below to -`memind-integrations/codex/scripts/lib/tool_context.py`. They use the shared top-level metadata contract: -`projectSlug`, `files`, `commands`, and `toolNames`. - -```python -TOOL_CONTEXT_CATEGORIES = ["resolution", "tool", "playbook", "directive"] - - -def load_tool_context(client, user_id, agent_id, target, config): - max_items = int(config.get("toolContextMaxItems", 6)) - min_exact = int(config.get("toolContextMinExactItems", 2)) - exact_items = [] - - for include_project in [True, False]: - metadata_filter = build_metadata_filter(target, include_project=include_project) - if not metadata_filter["any"]: - continue - response = client.query_items( - user_id=user_id, - agent_id=agent_id, - scope=None, - categories=TOOL_CONTEXT_CATEGORIES, - source_clients=None, - raw_data_types=["agent_timeline"], - metadata_filter=metadata_filter, - limit=max(10, max_items * 3), - ) - exact_items.extend(_normalize_items(getattr(response, "items", []) or [])) - if len(exact_items) >= max_items: - break - - raw_data = [] - metadata_filter = build_metadata_filter(target, include_project=bool(target.get("projectSlug"))) - if metadata_filter["any"]: - response = client.query_raw_data( - user_id=user_id, - agent_id=agent_id, - types=["agent_timeline"], - source_clients=None, - metadata_filter=metadata_filter, - include={"metadata": True, "segment": False}, - limit=max(6, max_items), - ) - raw_data = _normalize_raw_data(getattr(response, "raw_data", []) or []) - - fallback_items = [] - if len(exact_items) < min_exact: - retrieve_response = client.retrieve( - user_id, - agent_id, - _semantic_query(target), - config.get("retrieveStrategy", "SIMPLE"), - False, - scope=None, - categories=TOOL_CONTEXT_CATEGORIES, - metadata_filter=( - {"all": [{"path": "projectSlug", "op": "eq", "value": target["projectSlug"]}]} - if target.get("projectSlug") - else None - ), - include={"rawDataMetadata": True}, - ) - fallback_items = _normalize_items(getattr(retrieve_response, "items", []) or []) - - items = _dedupe_by_id(exact_items + fallback_items) - return { - "target": dict(target), - "items": rank_items(items, target)[:max_items], - "rawData": rank_raw_data(raw_data, target)[:max_items], - } - - -def _semantic_query(target): - parts = [] - if target.get("prompt"): - parts.append("task: " + target["prompt"]) - if target.get("path"): - parts.append("file: " + target["path"]) - if target.get("command"): - parts.append("command: " + target["command"]) - if target.get("toolName"): - parts.append("tool: " + target["toolName"]) - return "\n".join(parts) or "coding agent tool context" - - -def _normalize_items(items): - result = [] - for item in items: - result.append( - { - "id": _field(item, "id"), - "text": _field(item, "text"), - "category": str(_field(item, "category") or "memory").lower(), - "createdAt": _field(item, "createdAt") or _field(item, "created_at"), - "metadata": _field(item, "metadata") or {}, - } - ) - return [item for item in result if item.get("text")] - - -def _normalize_raw_data(raw_data): - result = [] - for raw in raw_data: - result.append( - { - "id": _field(raw, "rawDataId") or _field(raw, "raw_data_id") or _field(raw, "id"), - "caption": _field(raw, "caption"), - "type": _field(raw, "type"), - "createdAt": _field(raw, "createdAt") or _field(raw, "created_at"), - "metadata": _field(raw, "metadata") or {}, - } - ) - return [raw for raw in result if raw.get("caption") or raw.get("metadata")] - - -def rank_items(items, target): - return sorted( - items, - key=lambda item: ( - _match_score(item.get("metadata") or {}, target), - _category_score(item.get("category")), - item.get("createdAt") or "", - item.get("id") or "", - ), - reverse=True, - ) - - -def rank_raw_data(raw_data, target): - return sorted( - raw_data, - key=lambda raw: ( - _match_score(raw.get("metadata") or {}, target), - raw.get("createdAt") or "", - raw.get("id") or "", - ), - reverse=True, - ) - - -def _match_score(metadata, target): - score = 0 - if target.get("projectSlug") and metadata.get("projectSlug") == target["projectSlug"]: - score += 3 - if target.get("path") and target["path"] in metadata.get("files", []): - score += 10 - if target.get("command") and target["command"] in metadata.get("commands", []): - score += 8 - if target.get("toolName") and target["toolName"] in metadata.get("toolNames", []): - score += 3 - stats = metadata.get("toolStats") or {} - if target.get("toolName") in stats: - tool_stats = stats[target["toolName"]] - score += int(tool_stats.get("successCount") or 0) - return score - - -def _category_score(category): - return {"resolution": 5, "tool": 4, "playbook": 3, "directive": 2}.get(category or "", 1) - - -def _dedupe_by_id(items): - result = [] - seen = set() - for item in items: - key = item.get("id") or item.get("text") - if key in seen: - continue - seen.add(key) - result.append(item) - return result - - -def _field(value, name): - if isinstance(value, dict): - return value.get(name) - return getattr(value, name, None) -``` - -- [ ] **Step 6: Run tests and verify pass** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_tool_context.py \ - memind-integrations/codex/tests/test_tool_context.py -``` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add \ - memind-integrations/claude-code/scripts/lib/tool_context.py \ - memind-integrations/claude-code/tests/test_tool_context.py \ - memind-integrations/codex/scripts/lib/tool_context.py \ - memind-integrations/codex/tests/test_tool_context.py -git commit -m "feat(agent): query pre-tool memory context" -``` - ---- - -### Task 5: Add The PreToolUse Context Compiler - -**Files:** -- Modify: `memind-integrations/claude-code/scripts/lib/context_compiler.py` -- Modify: `memind-integrations/claude-code/tests/test_context_compiler.py` -- Modify: `memind-integrations/codex/scripts/lib/context_compiler.py` -- Modify: `memind-integrations/codex/tests/test_context_compiler.py` - -- [ ] **Step 1: Add failing compiler test for file edit context** - -Append to both `test_context_compiler.py` files: - -```python -def test_tool_context_compiler_renders_bounded_file_context(self): - from scripts.lib.context_compiler import compile_tool_context - - rendered = compile_tool_context( - { - "target": { - "toolName": "Edit", - "kind": "file_edit", - "path": "src/payment/calc.ts", - "projectSlug": "payment-service-abc", - }, - "items": [ - { - "id": "res-1", - "category": "resolution", - "text": "rounding mismatch was resolved in src/payment/calc.ts and validated with npm test payment.", - "metadata": { - "files": ["src/payment/calc.ts"], - "commands": ["npm test payment"], - }, - }, - { - "id": "tool-1", - "category": "tool", - "text": "Use npm test payment to validate changes touching src/payment/calc.ts; it failed once and passed once in this agent episode.", - "metadata": { - "files": ["src/payment/calc.ts"], - "commands": ["npm test payment"], - "toolStats": {"Bash": {"successCount": 1, "failCount": 1}}, - }, - }, - { - "id": "pb-1", - "category": "playbook", - "text": "When payment calculation logic changes, update focused tests first, then run npm test payment.", - "metadata": {}, - }, - ], - "rawData": [ - { - "id": "rd-1", - "caption": "Edited src/payment/calc.ts and validated npm test payment.", - "metadata": { - "toolStats": {"Bash": {"successCount": 1, "failCount": 1}}, - }, - } - ], - }, - {"toolContextMaxChars": 3500, "toolContextEntryMaxChars": 520}, - ) - - self.assertIn('")) -``` - -- [ ] **Step 2: Add failing compiler test for command context and truncation** - -Append to both `test_context_compiler.py` files: - -```python -def test_tool_context_compiler_renders_command_context_with_budget(self): - from scripts.lib.context_compiler import compile_tool_context - - rendered = compile_tool_context( - { - "target": { - "toolName": "Bash", - "kind": "test_result", - "command": "npm test payment", - "projectSlug": "payment-service-abc", - }, - "items": [ - { - "id": "tool-1", - "category": "tool", - "text": "Use npm test payment after editing payment calculation files. " + "x" * 900, - "metadata": {"commands": ["npm test payment"]}, - }, - { - "id": "dir-1", - "category": "directive", - "text": "Do not skip focused payment validation after touching calculation code.", - "metadata": {}, - }, - ], - "rawData": [], - }, - {"toolContextMaxChars": 900, "toolContextEntryMaxChars": 260}, - ) - - self.assertLessEqual(len(rendered), 900) - self.assertIn('")) -``` - -- [ ] **Step 3: Run compiler tests to verify failure** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_context_compiler.py \ - memind-integrations/codex/tests/test_context_compiler.py -``` - -Expected: FAIL because `compile_tool_context` does not exist. - -- [ ] **Step 4: Add compiler constants and entrypoint** - -In both `scripts/lib/context_compiler.py` files, add after `PROMPT_SECTION_LIMITS`: - -```python -TOOL_SECTION_ORDER = [ - ("priorResolutions", "## Prior Resolutions"), - ("validationNotes", "## Validation Notes"), - ("relevantPlaybooks", "## Relevant Playbooks"), - ("directives", "## Directives"), - ("recentEvidence", "## Recent Evidence"), -] - -TOOL_SECTION_BUDGETS = { - "priorResolutions": 900, - "validationNotes": 700, - "relevantPlaybooks": 700, - "directives": 500, - "recentEvidence": 700, -} -``` - -Update `SECTION_FIT_PRIORITY`: - -```python -"memind_tool_context": [ - "priorResolutions", - "validationNotes", - "directives", - "relevantPlaybooks", - "recentEvidence", -], -``` - -Add this function before `_prepare_sections`: - -```python -def compile_tool_context(context, config): - target = context.get("target") or {} - items = [_normalize_tool_item(item) for item in context.get("items") or [] if _field(item, "text")] - raw_data = [_normalize_tool_rawdata(raw) for raw in context.get("rawData") or []] - sections = { - "priorResolutions": _top_category(items, "resolution", 2), - "validationNotes": _top_category(items, "tool", 3), - "relevantPlaybooks": _top_category(items, "playbook", 2), - "directives": _top_category(items, "directive", 2), - "recentEvidence": raw_data[:2], - } - - attrs = {"tool": target.get("toolName") or "unknown"} - if target.get("path"): - attrs["file"] = target["path"] - if target.get("command"): - attrs["command"] = target["command"] - if target.get("projectSlug"): - attrs["project"] = target["projectSlug"] - - return _render_context( - wrapper="memind_tool_context", - attrs=attrs, - preamble=( - "Use only if directly relevant to this exact tool call. " - "Current user instructions and repository files take precedence. " - "Verify old details against the working tree before relying on them." - ), - sections=_prepare_sections(sections, "tool_context"), - order=TOOL_SECTION_ORDER, - budgets=TOOL_SECTION_BUDGETS, - max_chars=int(config.get("toolContextMaxChars", 3500)), - entry_max_chars=int(config.get("toolContextEntryMaxChars", 520)), - ) -``` - -- [ ] **Step 5: Keep total-budget fitting order stable for tool context** - -In both `context_compiler.py` files, add this constant after `TOOL_SECTION_ORDER`: - -```python -ALL_SECTION_ORDER = SESSION_SECTION_ORDER + PROMPT_SECTION_ORDER + TOOL_SECTION_ORDER -``` - -In `_fit_sections_to_total_budget(...)`, replace the final `selected_sections.sort(...)` line with: - -```python -order_index = {key: index for index, (key, _title) in enumerate(ALL_SECTION_ORDER)} -selected_sections.sort(key=lambda item: order_index.get(item[0], 999)) -``` - -This keeps `` section ordering stable even when `toolContextMaxChars` forces lower-priority -sections to be omitted. - -- [ ] **Step 6: Add tool item/rawdata normalizers** - -Add these helpers before `_rank_entries` in both `context_compiler.py` files: - -```python -def _normalize_tool_item(item): - category = str(_field(item, "category") or "memory").strip().lower() - return { - "kind": "item", - "id": _field(item, "id"), - "category": category, - "text": _clean(_field(item, "text")), - "createdAt": _field(item, "createdAt") or _field(item, "created_at"), - "score": _number(_field(item, "score"), _field(item, "finalScore"), 0), - } - - -def _normalize_tool_rawdata(raw): - text = _field(raw, "caption") or _recent_evidence_from_metadata(_field(raw, "metadata") or {}) - return { - "kind": "rawdata", - "id": _field(raw, "id") or _field(raw, "rawDataId") or _field(raw, "raw_data_id"), - "category": "agent_timeline", - "text": _clean(text), - "createdAt": _field(raw, "createdAt") or _field(raw, "created_at"), - "score": 0, - } - - -def _recent_evidence_from_metadata(metadata): - stats = metadata.get("toolStats") or {} - parts = [] - for tool_name, stat in stats.items(): - success = int(stat.get("successCount") or 0) - failed = int(stat.get("failCount") or 0) - if success or failed: - parts.append(f"{tool_name} failed {failed} time(s) and passed {success} time(s)") - return "; ".join(parts) -``` - -Do not render token counts or durations. - -- [ ] **Step 7: Run compiler tests and verify pass** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_context_compiler.py \ - memind-integrations/codex/tests/test_context_compiler.py -``` - -Expected: PASS. - -- [ ] **Step 8: Commit** - -```bash -git add \ - memind-integrations/claude-code/scripts/lib/context_compiler.py \ - memind-integrations/claude-code/tests/test_context_compiler.py \ - memind-integrations/codex/scripts/lib/context_compiler.py \ - memind-integrations/codex/tests/test_context_compiler.py -git commit -m "feat(agent): compile pre-tool memory context" -``` - ---- - -### Task 6: Wire Claude Code PreToolUse Injection - -**Files:** -- Modify: `memind-integrations/claude-code/scripts/pre_tool_use.py` -- Modify: `memind-integrations/claude-code/tests/test_hooks.py` - -- [ ] **Step 1: Add failing Claude Code hook injection test** - -In `memind-integrations/claude-code/tests/test_hooks.py`, add: - -```python -def test_pre_tool_use_injects_tool_context_when_memind_returns_matches(self): - sys.path.insert(0, str(ROOT / "scripts")) - import pre_tool_use - from scripts.lib.state import SessionStateStore - - config = { - "memindApiUrl": "http://127.0.0.1:8366", - "memindApiToken": None, - "sourceClient": "claude-code", - "agentId": "coding-agent", - "userId": "u", - "autoRetrieve": True, - "autoToolContext": True, - "toolContextMaxItems": 6, - "toolContextMinExactItems": 1, - "toolContextMaxChars": 3500, - "toolContextEntryMaxChars": 520, - "retrieveStrategy": "SIMPLE", - } - - with tempfile.TemporaryDirectory() as tmp: - state_dir = Path(tmp) / "state" - store = SessionStateStore(state_dir) - with store.locked("s1") as state: - turn_id, turn_seq = state.start_agent_turn("s1") - state.append_agent_event( - { - "eventId": "prompt", - "seq": 1, - "kind": "user_prompt", - "text": "Fix payment tests", - "metadata": {"turnId": turn_id, "turnSeq": turn_seq}, - } - ) - - class FakeClient: - def query_items(self, **kwargs): - return types.SimpleNamespace( - items=[ - types.SimpleNamespace( - id="res-1", - text="rounding mismatch was resolved in src/payment/calc.ts and validated with npm test payment.", - category="resolution", - created_at="2026-05-27T10:00:00Z", - metadata={ - "projectSlug": "tmp-project", - "files": ["src/payment/calc.ts"], - }, - ) - ] - ) - - def query_raw_data(self, **kwargs): - return types.SimpleNamespace(raw_data=[]) - - def retrieve(self, *args, **kwargs): - return types.SimpleNamespace(items=[], insights=[], raw_data=[]) - - with mock.patch.object(pre_tool_use, "state_root", return_value=state_dir): - with mock.patch.object(pre_tool_use, "load_config", return_value=config): - with mock.patch.object(pre_tool_use, "resolve_identity", return_value={"userId": "u", "agentId": "coding-agent"}): - with mock.patch.object(pre_tool_use, "MemindClient", return_value=FakeClient()): - output = pre_tool_use.handle_pre_tool_use( - { - "hook_event_name": "PreToolUse", - "cwd": tmp, - "session_id": "s1", - "tool_name": "Edit", - "tool_input": {"file_path": "src/payment/calc.ts"}, - "timestamp": "2026-05-28T10:00:00Z", - } - ) - - self.assertIn("hookSpecificOutput", output) - context = output["hookSpecificOutput"]["additionalContext"] - self.assertIn(" -Use only if directly relevant to this exact tool call. Current user instructions and repository files take precedence. - -## Prior Resolutions -- [item:res-1 resolution] rounding mismatch was resolved in src/payment/calc.ts and validated with npm test payment. - -## Validation Notes -- [item:tool-1 tool] Use npm test payment to validate changes touching src/payment/calc.ts. - -``` - -The context is built from existing Memind items and `agent_episode` metadata. It does not add extra LLM calls and does -not submit duplicate `tool_call` raw data. -```` - -Document settings: - -```markdown -| `autoToolContext` | `true` | Enable compact PreToolUse context for high-value file edits and commands. | -| `toolContextMaxChars` | `3500` | Maximum injected PreToolUse context characters. | -| `toolContextEntryMaxChars` | `520` | Maximum characters per PreToolUse context entry. | -| `toolContextMaxItems` | `6` | Maximum exact or fallback items considered for PreToolUse context. | -| `toolContextMinExactItems` | `2` | Minimum exact item hits before semantic retrieve fallback is skipped. | -``` - -- [ ] **Step 2: Update Codex README** - -In the Codex hook table, change `PreToolUse` description to: - -```markdown -| `PreToolUse` | `scripts/pre_tool_use.py` | 5s | Buffer a redacted tool-start event and, for high-value file edits or commands, inject compact file/tool memory context. | -``` - -Add or update the synchronous hook note: - -```markdown -`PreToolUse` is intentionally synchronous because it may inject a small context block before the tool executes. The hook -fails open and skips retrieval for low-value tools. `PostToolUse` and `Stop` keep their existing ingestion behavior. -``` - -Add this section after the Codex retrieval behavior section: - -````markdown -### PreToolUse Context - -For high-value tools such as `Edit`, `Write`, `MultiEdit`, and validation shell commands, Memind may inject a compact -tool-specific context block: - -```text - -Use only if directly relevant to this exact tool call. Current user instructions and repository files take precedence. - -## Prior Resolutions -- [item:res-1 resolution] rounding mismatch was resolved in src/payment/calc.ts and validated with npm test payment. - -## Validation Notes -- [item:tool-1 tool] Use npm test payment to validate changes touching src/payment/calc.ts. - -``` - -The context is built from existing Memind items and `agent_episode` metadata. It does not add extra LLM calls and does -not submit duplicate `tool_call` raw data. -```` - -Document the Codex settings: - -```markdown -| `autoToolContext` | `true` | Enable compact PreToolUse context for high-value file edits and commands. | -| `toolContextMaxChars` | `3500` | Maximum injected PreToolUse context characters. | -| `toolContextEntryMaxChars` | `520` | Maximum characters per PreToolUse context entry. | -| `toolContextMaxItems` | `6` | Maximum exact or fallback items considered for PreToolUse context. | -| `toolContextMinExactItems` | `2` | Minimum exact item hits before semantic retrieve fallback is skipped. | -``` - -- [ ] **Step 3: Update the rawdata-agent design spec** - -In `docs/superpowers/specs/2026-05-24-rawdata-agent-design.md`, add a short section: - -```markdown -### PreToolUse Context - -`rawdata-agent` stores enough deterministic file/tool metadata to support a retrieval-time PreToolUse context compiler. -The compiler should use existing `tool`, `resolution`, `playbook`, `directive`, and `agent_episode` data. It must not -change the rawdata storage model, must not run `rawdata-toolcall` extraction, and must not add per-tool LLM calls. -``` - -- [ ] **Step 4: Commit** - -```bash -git add \ - memind-integrations/claude-code/README.md \ - memind-integrations/codex/README.md \ - docs/superpowers/specs/2026-05-24-rawdata-agent-design.md -git commit -m "docs(agent): describe pre-tool context" -``` - ---- - -### Task 10: Full Verification - -**Files:** -- No source changes unless verification exposes a required fix. - -- [ ] **Step 1: Run Claude Code integration tests** - -Run: - -```bash -python3 -m unittest discover -s memind-integrations/claude-code/tests -``` - -Expected: PASS. - -- [ ] **Step 2: Run Codex integration tests** - -Run: - -```bash -/opt/homebrew/bin/python3.12 -m unittest discover -s memind-integrations/codex/tests -``` - -Expected: PASS. - -- [ ] **Step 3: Run focused timeline tests** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_agent_timeline.py \ - memind-integrations/codex/tests/test_agent_timeline.py -``` - -Expected: PASS. This confirms the PreToolUse event-buffering behavior still works with the existing `agent_timeline` flow. - -- [ ] **Step 4: Check whitespace** - -Run: - -```bash -git diff --check -``` - -Expected: no output. - -- [ ] **Step 5: Inspect hook manifest diff** - -Run: - -```bash -git diff -- memind-integrations/claude-code/hooks/hooks.json memind-integrations/codex/hooks/hooks.json -``` - -Expected: - -- Claude Code `PreToolUse` no longer has `"async": true`. -- Claude Code `PostToolUse`, `Stop`, `Notification`, and `SubagentStop` still have `"async": true`. -- Codex remains synchronous and still has only `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `Stop`. - -- [ ] **Step 6: Inspect rawdata-agent and rawdata-toolcall diffs** - -Run: - -```bash -git diff -- memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-toolcall -``` - -Expected: no Java plugin changes for this plan. - -- [ ] **Step 7: Commit verification-only fixes if needed** - -If formatting or docs fixes were required: - -```bash -git add -git commit -m "chore(agent): finalize pre-tool context" -``` - -- [ ] **Step 8: Push branch** - -Run: - -```bash -git push origin feat/rawdata-agent-memory -``` - -Expected: branch pushed successfully. - ---- - -## Acceptance Checklist - -- [ ] Claude Code and Codex still buffer PreToolUse events into the same local `agent_timeline` state. -- [ ] Claude Code PreToolUse is synchronous only because it may inject context. -- [ ] PostToolUse/Stop ingestion paths remain async where they were async before. -- [ ] PreToolUse skips low-value read/search/list tools. -- [ ] PreToolUse fails open when Memind is unavailable. -- [ ] PreToolUse exact retrieval uses top-level metadata fields: `projectSlug`, `files`, `commands`, `toolNames`. -- [ ] PreToolUse fallback retrieval is bounded and category-filtered. -- [ ] The injected context is wrapped in ``. -- [ ] Injected context defaults to about 500-800 tokens and is hard-capped by `toolContextMaxChars`. -- [ ] Injected context does not render raw `durationMs`, `inputTokens`, or `outputTokens`. -- [ ] No rawdata-agent storage changes are required. -- [ ] No rawdata-toolcall changes are required. -- [ ] No new LLM calls are added. -- [ ] Existing UserPromptSubmit and SessionStart context compilers keep their current behavior. - -## Self-Review Notes - -- Spec coverage: The plan implements PreToolUse context from existing storage, exact metadata retrieval, semantic fallback, context compilation, Claude Code/Codex wiring, settings, docs, installers, and verification. -- Placeholder scan: No TODO/TBD placeholders are present. Code snippets define the functions and assertions needed by later steps. -- Type consistency: The plan consistently uses `autoToolContext`, `toolContextMaxChars`, `toolContextEntryMaxChars`, `toolContextMaxItems`, `toolContextMinExactItems`, `compile_tool_context`, `load_tool_context`, and ``. -- Scope check: This is a retrieval-time integration feature. It deliberately avoids Memind core schema/API changes and rawdata-agent storage changes. diff --git a/docs/superpowers/plans/2026-05-28-agent-prompt-context-policy.md b/docs/superpowers/plans/2026-05-28-agent-prompt-context-policy.md deleted file mode 100644 index 735c4736..00000000 --- a/docs/superpowers/plans/2026-05-28-agent-prompt-context-policy.md +++ /dev/null @@ -1,1552 +0,0 @@ -# Agent Prompt Context Policy Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make prompt-time memory injection opt-in by default, and when enabled, retrieve with project-first ranking plus global fallback instead of broad unfiltered recall. - -**Architecture:** Keep Memind core, OpenAPI, rawdata-agent storage, SessionStart context, PreToolUse context, and Stop-time extraction unchanged. Add an integration-local `autoPromptContext` switch for `UserPromptSubmit`; `UserPromptSubmit` always buffers the prompt event, but only retrieves and injects `` when this switch is enabled. The enabled path performs a project-filtered retrieve first, then a bounded unfiltered fallback for reusable cross-project/global memories, dedupes and labels source provenance before rendering. - -**Tech Stack:** Python hook integrations for Claude Code and Codex, Memind Python client, existing retrieve metadata filters, unittest, JSON hook settings, Markdown integration docs. - ---- - -## Scope And Non-Goals - -In scope: - -- Add `autoPromptContext=false` to Claude Code and Codex default settings. -- Add `MEMIND_AUTO_PROMPT_CONTEXT` env override. -- Keep `autoRetrieve` as a backward-compatible broad retrieval gate for now, but stop using it as the only prompt-injection switch. -- Ensure `UserPromptSubmit` still appends a normalized `user_prompt` event even when prompt context is disabled. -- Add project-first prompt retrieval using current `metadata.projectSlug`. -- Add bounded global fallback using the same `userId + agentId` memory space without forcing `projectSlug == currentProject`. -- Merge project and fallback results with stable dedupe and project-first precedence. -- Add source labels to rendered prompt memories: `project`, `global`, or `shared`. -- Update Claude Code and Codex docs so users understand prompt-time context is opt-in and separate from SessionStart/PreToolUse. - -Out of scope: - -- No Memind core changes. -- No OpenAPI schema changes. -- No new server-side metadata operators. -- No new `contextLevel` or context-scope field on items. -- No per-prompt LLM gate. -- No per-tool LLM extraction. -- No change to rawdata-agent item extraction. -- No removal of `autoRetrieve` in this plan; it remains a compatibility gate because existing PreToolUse code still checks it. - -## Current Behavior - -Claude Code and Codex both currently do this in `scripts/retrieve.py`: - -1. Read `UserPromptSubmit` hook JSON. -2. Append a `user_prompt` event to local durable state. -3. If `autoRetrieve` is false, return `{"continue": true}`. -4. Build a query from the prompt plus optional recent transcript turns. -5. Call `client.retrieve(userId, agentId, query, strategy, false)` without project metadata filtering. -6. Render `` and inject it into the next model turn. - -This makes prompt-time injection default-on and broad across the whole shared `userId + agentId` memory space. That is useful when precise, but it can spend tokens every turn and can surface memories from unrelated projects. - -## Target Behavior - -Claude Code and Codex `UserPromptSubmit` should behave like this: - -```text -UserPromptSubmit - -> append USER_PROMPT event to local timeline state - -> if autoRetrieve=false: return continue - -> if autoPromptContext=false: return continue - -> build prompt query - -> resolve projectSlug from cwd - -> retrieve current-project memories first - -> if project hits are sparse, retrieve unfiltered fallback memories - -> dedupe project + fallback results - -> render - -> inject additionalContext -``` - -Default install behavior: - -```json -{ - "autoPromptContext": false, - "autoSessionContext": true, - "autoToolContext": true, - "autoIngestAgentTimeline": true -} -``` - -Enabled prompt context behavior: - -- Project retrieve uses `metadataFilter.all = [{ "path": "projectSlug", "op": "eq", "value": currentProjectSlug }]`. -- Global fallback uses no project filter, but only fills remaining budget. -- Current-project results always beat fallback duplicates. -- Fallback entries are retained only when they are sufficiently relevant or clearly reusable. -- Rendered context explicitly marks provenance so the agent can treat old cross-project information cautiously. - -## File Map - -Claude Code: - -- Modify `memind-integrations/claude-code/settings.json` - - Add prompt context settings with default disabled. - -- Modify `memind-integrations/claude-code/scripts/lib/config.py` - - Add defaults and env overrides. - -- Create `memind-integrations/claude-code/scripts/lib/prompt_context.py` - - Build project-first and fallback retrieve calls. - - Convert Memind response objects to dictionaries. - - Mark provenance and dedupe. - -- Modify `memind-integrations/claude-code/scripts/retrieve.py` - - Keep buffering behavior. - - Check `autoPromptContext`. - - Use `build_prompt_context(...)`. - -- Modify `memind-integrations/claude-code/scripts/lib/context_compiler.py` - - Render source labels and wrapper attrs for prompt context. - -- Modify tests: - - `memind-integrations/claude-code/tests/test_config.py` - - `memind-integrations/claude-code/tests/test_manifest.py` - - `memind-integrations/claude-code/tests/test_context_compiler.py` - - `memind-integrations/claude-code/tests/test_hooks.py` - - Create `memind-integrations/claude-code/tests/test_prompt_context.py` - -- Modify docs: - - `memind-integrations/claude-code/README.md` - -Codex: - -- Modify `memind-integrations/codex/settings.json` -- Modify `memind-integrations/codex/scripts/lib/config.py` -- Create `memind-integrations/codex/scripts/lib/prompt_context.py` -- Modify `memind-integrations/codex/scripts/retrieve.py` -- Modify `memind-integrations/codex/scripts/lib/context_compiler.py` -- Modify tests: - - `memind-integrations/codex/tests/test_config.py` - - `memind-integrations/codex/tests/test_manifest.py` - - `memind-integrations/codex/tests/test_context_compiler.py` - - `memind-integrations/codex/tests/test_hooks.py` - - Create `memind-integrations/codex/tests/test_prompt_context.py` -- Modify docs: - - `memind-integrations/codex/README.md` - -## Config Contract - -Add these settings to both integrations: - -```json -{ - "autoPromptContext": false, - "promptContextProjectMinEntries": 4, - "promptContextGlobalFallbackEntries": 3, - "promptContextGlobalFallbackMinScore": 0.65 -} -``` - -Add these env vars to both `ENV_MAP` dictionaries: - -```python -"MEMIND_AUTO_PROMPT_CONTEXT": ("autoPromptContext", "bool"), -"MEMIND_PROMPT_CONTEXT_PROJECT_MIN_ENTRIES": ("promptContextProjectMinEntries", "int_allow_zero"), -"MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_ENTRIES": ("promptContextGlobalFallbackEntries", "int_allow_zero"), -"MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_MIN_SCORE": ("promptContextGlobalFallbackMinScore", "float_allow_zero"), -``` - -Add float parsing support: - -```python -def parse_float(value, name, allow_zero=False): - parsed = float(value) - if parsed < 0 or (parsed == 0 and not allow_zero): - raise ValueError(f"{name} must be positive") - return parsed -``` - -Extend `_coerce(...)`: - -```python -if kind == "float_allow_zero": - return parse_float(value, name, allow_zero=True) -``` - -`autoRetrieve` remains supported: - -- `autoRetrieve=false` disables prompt retrieve and tool retrieve fallback, as today. -- `autoPromptContext=false` disables only UserPromptSubmit context injection. -- `autoToolContext=false` disables only PreToolUse context injection. -- `autoSessionContext=false` disables only SessionStart context injection. - -## Prompt Context Helper Contract - -Create `scripts/lib/prompt_context.py` in both integrations. - -Public function: - -```python -def build_prompt_context(client, identity, query, project_slug, config): - ... -``` - -Input: - -- `client`: existing local `MemindClient`. -- `identity`: `{"userId": "...", "agentId": "..."}`. -- `query`: prompt plus optional recent context. -- `project_slug`: result of `lib.identity.project_slug(cwd)`. -- `config`: loaded integration config. - -Output: - -Dictionary suitable for `compile_prompt_retrieval_context(...)`: - -```python -{ - "projectSlug": project_slug, - "mode": "project-first", - "items": [...], - "insights": [...], - "rawData": [...], -} -``` - -Each item/insight should carry a local provenance field: - -```python -{ - "id": "it-1", - "text": "Use metadata.projectSlug for project isolation.", - "category": "directive", - "metadata": {"projectSlug": "memind-a1b2c3"}, - "finalScore": 0.91, - "memindContextSource": "project" -} -``` - -Allowed `memindContextSource` values: - -- `project`: `metadata.projectSlug == currentProjectSlug` -- `global`: no `metadata.projectSlug` -- `shared`: `metadata.projectSlug` exists and differs from current project - -Fallback retrieve should not be called when project retrieve already returns enough usable entries. - -Implementation detail: - -- Always call `_dump_response()` first and then `_mark_sources(...)` on dumped dictionaries. -- Do not attach `memindContextSource` to Python client Pydantic model instances before dump; `MemindModel` ignores extra fields, so the provenance label can be lost during `model_dump()`. -- Pass `default_source="project"` when marking the project-filtered retrieve response. Current `RetrievedInsight` responses do not carry metadata, so project-pass insights must inherit provenance from the retrieve pass rather than from entry metadata. - -## Dedupe And Ranking Rules - -Use a stable key: - -```python -def _entry_key(entry): - entry_id = _field(entry, "id") - if entry_id: - return f"id:{entry_id}" - text = " ".join(str(_field(entry, "text") or "").lower().split()) - return f"text:{text[:260]}" -``` - -Merge order: - -1. Project items. -2. Project insights. -3. Fallback items. -4. Fallback insights. - -When duplicate keys exist, keep the first entry. This guarantees project entries win over fallback duplicates. - -Fallback score filter: - -- Always drop fallback entries whose source is `project`; they are duplicates or project memories already covered by pass one. -- If a fallback entry has `finalScore`, `final_score`, `vectorScore`, `vector_score`, or `score`, keep it only when the score is at least `promptContextGlobalFallbackMinScore`. -- If a fallback insight has no score field, keep it and rely on Memind's returned order plus `promptContextGlobalFallbackEntries`. -- Do not treat missing insight scores as `0`; the Python client `RetrievedInsight` model does not currently expose score fields, so score-gating missing-score insights would drop valid fallback insights. -- Items should normally have scores, but the implementation should use the same score-present check so model/client shape differences do not accidentally remove all fallback data. - -Fallback size: - -- `promptContextGlobalFallbackEntries` applies separately to items and insights after filtering and dedupe. -- Default `3` keeps fallback compact. - -Project hit count: - -- Count usable project `items + insights`. -- If count >= `promptContextProjectMinEntries`, skip fallback. -- Default `4`. - -## Rendered Context Contract - -Update `compile_prompt_retrieval_context(...)` to read these optional fields: - -- `projectSlug` -- `mode` -- `memindContextSource` - -Wrapper: - -```xml - -``` - -Entry labels: - -```text -- [item:dir-1 directive, project, 2026-05-27] Keep userId and agentId stable. -- [item:profile-1 behavior, global, 2026-05-20] User prefers Chinese replies. -- [item:tool-9 tool, shared, 2026-05-18] After hook changes, run both Claude Code and Codex tests. -``` - -Use `shared` instead of another project's slug in the label to avoid leaking unrelated local repo names into the prompt context. The actual `metadata.projectSlug` stays in memory metadata, but not in the injected text. - -Preamble should remain cautious: - -```text -Relevant memories from Memind. Use only when directly helpful: -``` - -No new in-app explanatory text should be inserted beyond the existing preamble and labels. - ---- - -## Task 1: Add Prompt Context Config Defaults - -**Files:** - -- Modify `memind-integrations/claude-code/settings.json` -- Modify `memind-integrations/codex/settings.json` -- Modify `memind-integrations/claude-code/scripts/lib/config.py` -- Modify `memind-integrations/codex/scripts/lib/config.py` -- Test `memind-integrations/claude-code/tests/test_config.py` -- Test `memind-integrations/codex/tests/test_config.py` -- Test `memind-integrations/claude-code/tests/test_manifest.py` -- Test `memind-integrations/codex/tests/test_manifest.py` - -- [ ] **Step 1: Write failing Claude Code config tests** - -Add assertions to `memind-integrations/claude-code/tests/test_config.py`: - -```python -def test_prompt_context_defaults_are_opt_in(self): - from scripts.lib.config import DEFAULT_SETTINGS - - self.assertFalse(DEFAULT_SETTINGS["autoPromptContext"]) - self.assertEqual(DEFAULT_SETTINGS["promptContextProjectMinEntries"], 4) - self.assertEqual(DEFAULT_SETTINGS["promptContextGlobalFallbackEntries"], 3) - self.assertEqual(DEFAULT_SETTINGS["promptContextGlobalFallbackMinScore"], 0.65) - - -def test_prompt_context_env_overrides(self): - from scripts.lib.config import load_config - - config = load_config( - plugin_root=ROOT, - user_config_path=Path("/no/such/file"), - env={ - "CLAUDE_PLUGIN_ROOT": str(ROOT), - "MEMIND_AUTO_PROMPT_CONTEXT": "true", - "MEMIND_PROMPT_CONTEXT_PROJECT_MIN_ENTRIES": "2", - "MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_ENTRIES": "1", - "MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_MIN_SCORE": "0.5", - }, - ) - - self.assertTrue(config["autoPromptContext"]) - self.assertEqual(config["promptContextProjectMinEntries"], 2) - self.assertEqual(config["promptContextGlobalFallbackEntries"], 1) - self.assertEqual(config["promptContextGlobalFallbackMinScore"], 0.5) -``` - -- [ ] **Step 2: Write failing Codex config tests** - -Add equivalent assertions to `memind-integrations/codex/tests/test_config.py`, using `CODEX_PLUGIN_ROOT` where the existing tests use it: - -```python -def test_prompt_context_defaults_are_opt_in(self): - from scripts.lib.config import DEFAULT_SETTINGS - - self.assertFalse(DEFAULT_SETTINGS["autoPromptContext"]) - self.assertEqual(DEFAULT_SETTINGS["promptContextProjectMinEntries"], 4) - self.assertEqual(DEFAULT_SETTINGS["promptContextGlobalFallbackEntries"], 3) - self.assertEqual(DEFAULT_SETTINGS["promptContextGlobalFallbackMinScore"], 0.65) - - -def test_prompt_context_env_overrides(self): - from scripts.lib.config import load_config - - root = Path(__file__).resolve().parents[1] - config = load_config( - plugin_root=root, - user_config_path=Path("/no/such/file"), - env={ - "CODEX_PLUGIN_ROOT": str(root), - "MEMIND_AUTO_PROMPT_CONTEXT": "true", - "MEMIND_PROMPT_CONTEXT_PROJECT_MIN_ENTRIES": "2", - "MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_ENTRIES": "1", - "MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_MIN_SCORE": "0.5", - }, - ) - - self.assertTrue(config["autoPromptContext"]) - self.assertEqual(config["promptContextProjectMinEntries"], 2) - self.assertEqual(config["promptContextGlobalFallbackEntries"], 1) - self.assertEqual(config["promptContextGlobalFallbackMinScore"], 0.5) -``` - -- [ ] **Step 3: Run config tests and verify they fail** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_config.py \ - memind-integrations/codex/tests/test_config.py -``` - -Expected: failures because `autoPromptContext` and float parsing do not exist yet. - -- [ ] **Step 4: Implement config defaults** - -In both `scripts/lib/config.py`, add to `DEFAULT_SETTINGS` after `autoRetrieve`: - -```python -"autoPromptContext": False, -``` - -Add after `retrieveContextTurns` or near prompt retrieval options: - -```python -"promptContextProjectMinEntries": 4, -"promptContextGlobalFallbackEntries": 3, -"promptContextGlobalFallbackMinScore": 0.65, -``` - -Add to `ENV_MAP` after `MEMIND_AUTO_RETRIEVE`: - -```python -"MEMIND_AUTO_PROMPT_CONTEXT": ("autoPromptContext", "bool"), -``` - -Add near retrieval env vars: - -```python -"MEMIND_PROMPT_CONTEXT_PROJECT_MIN_ENTRIES": ("promptContextProjectMinEntries", "int_allow_zero"), -"MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_ENTRIES": ("promptContextGlobalFallbackEntries", "int_allow_zero"), -"MEMIND_PROMPT_CONTEXT_GLOBAL_FALLBACK_MIN_SCORE": ("promptContextGlobalFallbackMinScore", "float_allow_zero"), -``` - -Add float parsing: - -```python -def parse_float(value, name, allow_zero=False): - parsed = float(value) - if parsed < 0 or (parsed == 0 and not allow_zero): - raise ValueError(f"{name} must be positive") - return parsed -``` - -Add to `_coerce(...)`: - -```python -if kind == "float_allow_zero": - return parse_float(value, name, allow_zero=True) -``` - -- [ ] **Step 5: Update default settings JSON** - -Add these keys to both `settings.json` files: - -```json -"autoPromptContext": false, -"promptContextProjectMinEntries": 4, -"promptContextGlobalFallbackEntries": 3, -"promptContextGlobalFallbackMinScore": 0.65, -``` - -Keep `autoRetrieve` unchanged in this task. - -- [ ] **Step 6: Update manifest/default settings tests** - -In both manifest tests, assert that bundled settings include: - -```python -self.assertFalse(settings["autoPromptContext"]) -self.assertEqual(settings["promptContextProjectMinEntries"], 4) -self.assertEqual(settings["promptContextGlobalFallbackEntries"], 3) -self.assertEqual(settings["promptContextGlobalFallbackMinScore"], 0.65) -``` - -- [ ] **Step 7: Run config and manifest tests** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_config.py \ - memind-integrations/claude-code/tests/test_manifest.py \ - memind-integrations/codex/tests/test_config.py \ - memind-integrations/codex/tests/test_manifest.py -``` - -Expected: all pass. - -- [ ] **Step 8: Commit** - -```bash -git add \ - memind-integrations/claude-code/settings.json \ - memind-integrations/claude-code/scripts/lib/config.py \ - memind-integrations/claude-code/tests/test_config.py \ - memind-integrations/claude-code/tests/test_manifest.py \ - memind-integrations/codex/settings.json \ - memind-integrations/codex/scripts/lib/config.py \ - memind-integrations/codex/tests/test_config.py \ - memind-integrations/codex/tests/test_manifest.py -git commit -m "feat(agent): make prompt context opt-in" -``` - -## Task 2: Add Project-First Prompt Retrieval Helper - -**Files:** - -- Create `memind-integrations/claude-code/scripts/lib/prompt_context.py` -- Create `memind-integrations/codex/scripts/lib/prompt_context.py` -- Test `memind-integrations/claude-code/tests/test_prompt_context.py` -- Test `memind-integrations/codex/tests/test_prompt_context.py` - -- [ ] **Step 1: Write failing Claude Code prompt context tests** - -Create `memind-integrations/claude-code/tests/test_prompt_context.py`: - -```python -import sys -import unittest -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT)) -sys.path.insert(0, str(ROOT / "scripts")) - - -class PromptContextTests(unittest.TestCase): - def test_project_hits_skip_global_fallback(self): - from scripts.lib.prompt_context import build_prompt_context - - class FakeResponse: - def __init__(self, items=None, insights=None): - self.items = items or [] - self.insights = insights or [] - self.raw_data = [] - - def model_dump(self, by_alias=True): - return { - "items": self.items, - "insights": self.insights, - "rawData": self.raw_data, - } - - class FakeClient: - def __init__(self): - self.calls = [] - - def retrieve(self, *args, **kwargs): - self.calls.append(kwargs) - return FakeResponse( - items=[ - {"id": "p1", "text": "Project directive", "category": "directive", "metadata": {"projectSlug": "memind-main"}, "finalScore": 0.9}, - {"id": "p2", "text": "Project resolution", "category": "resolution", "metadata": {"projectSlug": "memind-main"}, "finalScore": 0.8}, - ], - insights=[ - {"id": "i1", "text": "Project insight", "tier": "root"}, - {"id": "i2", "text": "Project branch", "tier": "branch"}, - ], - ) - - client = FakeClient() - result = build_prompt_context( - client, - {"userId": "u", "agentId": "a"}, - "fix retrieval", - "memind-main", - { - "retrieveStrategy": "SIMPLE", - "promptContextProjectMinEntries": 4, - "promptContextGlobalFallbackEntries": 3, - "promptContextGlobalFallbackMinScore": 0.65, - }, - ) - - self.assertEqual(len(client.calls), 1) - self.assertEqual(client.calls[0]["metadata_filter"]["all"][0]["path"], "projectSlug") - self.assertEqual(client.calls[0]["metadata_filter"]["all"][0]["value"], "memind-main") - self.assertEqual(result["mode"], "project-first") - self.assertEqual(result["projectSlug"], "memind-main") - self.assertTrue(all(item["memindContextSource"] == "project" for item in result["items"])) - self.assertTrue(all(insight["memindContextSource"] == "project" for insight in result["insights"])) - - def test_sparse_project_hits_use_bounded_global_fallback(self): - from scripts.lib.prompt_context import build_prompt_context - - class FakeResponse: - def __init__(self, items=None, insights=None): - self.items = items or [] - self.insights = insights or [] - self.raw_data = [] - - def model_dump(self, by_alias=True): - return { - "items": self.items, - "insights": self.insights, - "rawData": self.raw_data, - } - - class FakeClient: - def __init__(self): - self.calls = [] - - def retrieve(self, *args, **kwargs): - self.calls.append(kwargs) - if len(self.calls) == 1: - return FakeResponse( - items=[ - {"id": "same", "text": "Project directive", "category": "directive", "metadata": {"projectSlug": "memind-main"}, "finalScore": 0.9} - ], - ) - return FakeResponse( - items=[ - {"id": "same", "text": "Duplicate project directive", "category": "directive", "metadata": {"projectSlug": "memind-main"}, "finalScore": 0.95}, - {"id": "g1", "text": "User prefers Chinese replies", "category": "behavior", "metadata": {}, "finalScore": 0.91}, - {"id": "s1", "text": "Run both integration tests after hook edits", "category": "tool", "metadata": {"projectSlug": "other-project"}, "finalScore": 0.8}, - {"id": "low", "text": "Weak unrelated memory", "category": "event", "metadata": {}, "finalScore": 0.2}, - ], - insights=[ - {"id": "gi1", "text": "Shared testing insight", "tier": "root", "metadata": {}} - ], - ) - - client = FakeClient() - result = build_prompt_context( - client, - {"userId": "u", "agentId": "a"}, - "fix retrieval", - "memind-main", - { - "retrieveStrategy": "SIMPLE", - "promptContextProjectMinEntries": 4, - "promptContextGlobalFallbackEntries": 3, - "promptContextGlobalFallbackMinScore": 0.65, - }, - ) - - self.assertEqual(len(client.calls), 2) - self.assertIsNone(client.calls[1].get("metadata_filter")) - item_ids = [item["id"] for item in result["items"]] - self.assertEqual(item_ids, ["same", "g1", "s1"]) - sources = {item["id"]: item["memindContextSource"] for item in result["items"]} - self.assertEqual(sources["same"], "project") - self.assertEqual(sources["g1"], "global") - self.assertEqual(sources["s1"], "shared") - self.assertNotIn("low", item_ids) - self.assertEqual(result["insights"][0]["memindContextSource"], "global") -``` - -- [ ] **Step 2: Write equivalent Codex prompt context tests** - -Copy the same test file to `memind-integrations/codex/tests/test_prompt_context.py`, changing only `ROOT` resolution if the existing Codex tests use a different convention. The assertions should remain identical. - -- [ ] **Step 3: Run new tests and verify they fail** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_prompt_context.py \ - memind-integrations/codex/tests/test_prompt_context.py -``` - -Expected: import failure because `scripts.lib.prompt_context` does not exist. - -- [ ] **Step 4: Implement `prompt_context.py` in Claude Code** - -Create `memind-integrations/claude-code/scripts/lib/prompt_context.py`: - -```python -def project_metadata_filter(project_slug): - return {"all": [{"path": "projectSlug", "op": "eq", "value": project_slug}]} - - -def build_prompt_context(client, identity, query, project_slug, config): - project_data = _retrieve( - client, - identity, - query, - config, - metadata_filter=project_metadata_filter(project_slug) if project_slug else None, - ) - _mark_sources(project_data, project_slug, default_source="project") - - project_count = len(project_data.get("items") or []) + len(project_data.get("insights") or []) - min_entries = int(config.get("promptContextProjectMinEntries", 4)) - fallback_limit = int(config.get("promptContextGlobalFallbackEntries", 3)) - - if project_count >= min_entries or fallback_limit <= 0: - return _shape(project_data, project_slug) - - fallback_data = _retrieve(client, identity, query, config, metadata_filter=None) - _mark_sources(fallback_data, project_slug) - fallback_data = _filter_fallback(fallback_data, config, fallback_limit) - - return _shape(_merge(project_data, fallback_data), project_slug) - - -def _retrieve(client, identity, query, config, metadata_filter=None): - response = client.retrieve( - identity["userId"], - identity["agentId"], - query, - config.get("retrieveStrategy", "SIMPLE"), - False, - metadata_filter=metadata_filter, - include={"raw_data_metadata": True}, - ) - return _dump_response(response) - - -def _dump_response(response): - if hasattr(response, "model_dump"): - data = response.model_dump(by_alias=True) - else: - data = { - "items": list(getattr(response, "items", []) or []), - "insights": list(getattr(response, "insights", []) or []), - "rawData": list(getattr(response, "raw_data", []) or getattr(response, "rawData", []) or []), - } - - return { - "items": [_dump_entry(entry) for entry in data.get("items", []) or []], - "insights": [_dump_entry(entry) for entry in data.get("insights", []) or []], - "rawData": [_dump_entry(entry) for entry in data.get("rawData", []) or data.get("raw_data", []) or []], - } - - -def _dump_entry(entry): - if isinstance(entry, dict): - return dict(entry) - if hasattr(entry, "model_dump"): - return entry.model_dump(by_alias=True) - return { - key: value - for key, value in vars(entry).items() - if not key.startswith("_") - } - - -def _shape(data, project_slug): - return { - "projectSlug": project_slug, - "mode": "project-first", - "items": data.get("items") or [], - "insights": data.get("insights") or [], - "rawData": data.get("rawData") or data.get("raw_data") or [], - } - - -def _merge(project_data, fallback_data): - return { - "items": _dedupe((project_data.get("items") or []) + (fallback_data.get("items") or [])), - "insights": _dedupe((project_data.get("insights") or []) + (fallback_data.get("insights") or [])), - "rawData": _dedupe((project_data.get("rawData") or []) + (fallback_data.get("rawData") or [])), - } - - -def _filter_fallback(data, config, limit): - min_score = float(config.get("promptContextGlobalFallbackMinScore", 0.65)) - return { - "items": [ - entry - for entry in data.get("items", []) - if entry.get("memindContextSource") != "project" and _passes_fallback_score(entry, min_score) - ][:limit], - "insights": [ - entry - for entry in data.get("insights", []) - if entry.get("memindContextSource") != "project" and _passes_fallback_score(entry, min_score) - ][:limit], - "rawData": [], - } - - -def _mark_sources(data, project_slug, default_source=None): - # Mark only dumped dictionaries, not Pydantic model objects. MemindModel uses - # extra="ignore", so annotating model instances before model_dump can lose - # memindContextSource. - for key in ("items", "insights", "rawData", "raw_data"): - for entry in data.get(key) or []: - entry["memindContextSource"] = _source_for(entry, project_slug, default_source) - - -def _source_for(entry, project_slug, default_source=None): - metadata = _field(entry, "metadata") or {} - entry_project = metadata.get("projectSlug") if isinstance(metadata, dict) else None - if entry_project and project_slug and entry_project == project_slug: - return "project" - if entry_project: - return "shared" - if default_source: - return default_source - return "global" - - -def _dedupe(entries): - result = [] - seen = set() - for entry in entries: - key = _entry_key(entry) - if not key or key in seen: - continue - seen.add(key) - result.append(entry) - return result - - -def _entry_key(entry): - entry_id = _field(entry, "id") or _field(entry, "rawDataId") or _field(entry, "raw_data_id") - if entry_id: - return f"id:{entry_id}" - text = " ".join(str(_field(entry, "text") or _field(entry, "caption") or "").lower().split()) - return f"text:{text[:260]}" if text else "" - - -def _passes_fallback_score(entry, min_score): - score = _score(entry) - if score is None: - return True - return score >= min_score - - -def _score(entry): - for key in ("finalScore", "final_score", "vectorScore", "vector_score", "score"): - value = _field(entry, key) - if value is None: - continue - try: - return float(value) - except (TypeError, ValueError): - continue - return None - - -def _field(value, name): - if isinstance(value, dict): - return value.get(name) - return getattr(value, name, None) - - -``` - -- [ ] **Step 5: Copy helper to Codex** - -Copy the same implementation into `memind-integrations/codex/scripts/lib/prompt_context.py`. - -- [ ] **Step 6: Run prompt context tests** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_prompt_context.py \ - memind-integrations/codex/tests/test_prompt_context.py -``` - -Expected: pass. - -- [ ] **Step 7: Commit** - -```bash -git add \ - memind-integrations/claude-code/scripts/lib/prompt_context.py \ - memind-integrations/claude-code/tests/test_prompt_context.py \ - memind-integrations/codex/scripts/lib/prompt_context.py \ - memind-integrations/codex/tests/test_prompt_context.py -git commit -m "feat(agent): retrieve prompt context project first" -``` - -## Task 3: Render Prompt Context Provenance - -**Files:** - -- Modify `memind-integrations/claude-code/scripts/lib/context_compiler.py` -- Modify `memind-integrations/codex/scripts/lib/context_compiler.py` -- Test `memind-integrations/claude-code/tests/test_context_compiler.py` -- Test `memind-integrations/codex/tests/test_context_compiler.py` - -- [ ] **Step 1: Write failing compiler tests** - -Add to both `test_context_compiler.py` files: - -```python -def test_prompt_context_renders_project_first_attrs_and_source_labels(self): - from scripts.lib.context_compiler import compile_prompt_retrieval_context - - rendered = compile_prompt_retrieval_context( - { - "projectSlug": "memind-main", - "mode": "project-first", - "items": [ - { - "id": "dir-1", - "text": "Keep userId and agentId stable.", - "category": "directive", - "createdAt": "2026-05-27T10:00:00Z", - "finalScore": 0.9, - "memindContextSource": "project", - }, - { - "id": "beh-1", - "text": "User prefers Chinese replies.", - "category": "behavior", - "createdAt": "2026-05-20T10:00:00Z", - "finalScore": 0.88, - "memindContextSource": "global", - }, - ], - "insights": [ - { - "id": "ins-1", - "text": "Run both Claude Code and Codex tests after hook edits.", - "tier": "root", - "createdAt": "2026-05-18T10:00:00Z", - "memindContextSource": "shared", - } - ], - }, - { - "retrieveMaxEntries": 8, - "retrieveMaxChars": 6000, - "retrievePromptPreamble": "Relevant memories from Memind.", - }, - ) - - self.assertIn('', rendered) - self.assertIn("[item:dir-1 directive, project, 2026-05-27]", rendered) - self.assertIn("[item:beh-1 behavior, global, 2026-05-20]", rendered) - self.assertIn("[insight:ins-1 root, shared, 2026-05-18]", rendered) -``` - -- [ ] **Step 2: Run compiler tests and verify failure** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_context_compiler.py \ - memind-integrations/codex/tests/test_context_compiler.py -``` - -Expected: failure because wrapper attrs and source labels are not rendered yet. - -- [ ] **Step 3: Update normalizers** - -In both `context_compiler.py`, add `source` to `_normalize_retrieved_item(...)`: - -```python -"source": _field(item, "memindContextSource"), -``` - -Add `source` to `_normalize_insight(...)`: - -```python -"source": _field(insight, "memindContextSource"), -``` - -No source field is needed for session or tool contexts. - -- [ ] **Step 4: Update prompt wrapper attrs** - -In `compile_prompt_retrieval_context(...)`, replace: - -```python -attrs={}, -``` - -with: - -```python -attrs=_prompt_attrs(data), -``` - -for both normal and notice-only render paths. - -Add helper: - -```python -def _prompt_attrs(data): - attrs = {} - if data.get("projectSlug"): - attrs["project"] = data["projectSlug"] - if data.get("mode"): - attrs["mode"] = data["mode"] - return attrs -``` - -- [ ] **Step 5: Update entry label rendering** - -In `_render_entry(...)`, after date handling, include source before date: - -```python -source = entry.get("source") -label_parts = [label] -if source: - label_parts.append(source) -if date: - label_parts.append(date) -label = ", ".join(label_parts) -``` - -Replace the existing: - -```python -if date: - label = f"{label}, {date}" -``` - -with the new `label_parts` logic. - -- [ ] **Step 6: Run compiler tests** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_context_compiler.py \ - memind-integrations/codex/tests/test_context_compiler.py -``` - -Expected: pass. - -- [ ] **Step 7: Commit** - -```bash -git add \ - memind-integrations/claude-code/scripts/lib/context_compiler.py \ - memind-integrations/claude-code/tests/test_context_compiler.py \ - memind-integrations/codex/scripts/lib/context_compiler.py \ - memind-integrations/codex/tests/test_context_compiler.py -git commit -m "feat(agent): label prompt memory provenance" -``` - -## Task 4: Wire Prompt Context Into UserPromptSubmit - -**Files:** - -- Modify `memind-integrations/claude-code/scripts/retrieve.py` -- Modify `memind-integrations/codex/scripts/retrieve.py` -- Test `memind-integrations/claude-code/tests/test_hooks.py` -- Test `memind-integrations/codex/tests/test_hooks.py` - -- [ ] **Step 1: Write failing Claude Code hook tests** - -Add to `memind-integrations/claude-code/tests/test_hooks.py`: - -```python -def test_retrieve_default_does_not_call_memind_but_buffers_prompt(self): - sys.path.insert(0, str(ROOT / "scripts")) - import retrieve - - retrieve = importlib.reload(retrieve) - - config = { - "sourceClient": "claude-code", - "autoRetrieve": True, - "autoPromptContext": False, - "retrieveContextTurns": 0, - } - - with tempfile.TemporaryDirectory() as tmp: - state_dir = Path(tmp) / "state" - with mock.patch.object(retrieve, "state_root", return_value=state_dir): - with mock.patch.object(retrieve, "load_config", return_value=config): - with mock.patch.object(retrieve, "MemindClient") as client_cls: - result = retrieve.handle_user_prompt_submit( - { - "hook_event_name": "UserPromptSubmit", - "cwd": tmp, - "session_id": "s1", - "prompt": "Fix payment tests", - } - ) - - self.assertEqual(result, {"continue": True}) - client_cls.assert_not_called() - state_file = next(state_dir.glob("*.json")) - event = json.loads(state_file.read_text())["agentEvents"][0] - self.assertEqual(event["kind"], "user_prompt") - self.assertEqual(event["text"], "Fix payment tests") - - -def test_retrieve_prompt_context_enabled_uses_project_first_context(self): - sys.path.insert(0, str(ROOT / "scripts")) - import retrieve - - retrieve = importlib.reload(retrieve) - - config = { - "sourceClient": "claude-code", - "memindApiUrl": "http://127.0.0.1:8366", - "memindApiToken": None, - "autoRetrieve": True, - "autoPromptContext": True, - "retrieveContextTurns": 0, - "retrieveStrategy": "SIMPLE", - "retrieveMaxEntries": 8, - "retrieveMaxChars": 6000, - "retrievePromptPreamble": "Relevant memories from Memind.", - "promptContextProjectMinEntries": 4, - "promptContextGlobalFallbackEntries": 3, - "promptContextGlobalFallbackMinScore": 0.65, - } - - class FakeClient: - pass - - with tempfile.TemporaryDirectory() as tmp: - state_dir = Path(tmp) / "state" - with mock.patch.object(retrieve, "state_root", return_value=state_dir): - with mock.patch.object(retrieve, "load_config", return_value=config): - with mock.patch.object(retrieve, "resolve_identity", return_value={"userId": "u", "agentId": "a"}): - with mock.patch.object(retrieve, "project_slug", return_value="memind-main"): - with mock.patch.object(retrieve, "MemindClient", return_value=FakeClient()): - with mock.patch.object( - retrieve, - "build_prompt_context", - return_value={ - "projectSlug": "memind-main", - "mode": "project-first", - "items": [ - { - "id": "dir-1", - "text": "Keep ids stable.", - "category": "directive", - "memindContextSource": "project", - } - ], - "insights": [], - }, - ) as build_context: - result = retrieve.handle_user_prompt_submit( - { - "hook_event_name": "UserPromptSubmit", - "cwd": tmp, - "session_id": "s1", - "prompt": "Fix payment tests", - } - ) - - build_context.assert_called_once() - args = build_context.call_args.args - self.assertEqual(args[3], "memind-main") - context = result["hookSpecificOutput"]["additionalContext"] - self.assertIn('', context) - self.assertIn("[item:dir-1 directive, project]", context) -``` - -If `retrieve.py` does not yet expose `handle_user_prompt_submit(...)`, the test should fail until Step 3 introduces it. - -- [ ] **Step 2: Write equivalent Codex hook tests** - -Add equivalent tests to `memind-integrations/codex/tests/test_hooks.py` with: - -- `sourceClient`: `"codex"` -- prompt field can be `"prompt"` or `"user_prompt"`; include one test using `"user_prompt"` to preserve Codex behavior. -- Use Codex `state_key(...)` behavior in assertions if existing tests use it. -- Include the same `build_context.call_args.args[3] == "memind-main"` assertion so the test verifies current-project attribution is passed into the helper. - -- [ ] **Step 3: Run hook tests and verify failure** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_hooks.py \ - memind-integrations/codex/tests/test_hooks.py -``` - -Expected: failure because `handle_user_prompt_submit(...)` and prompt helper wiring do not exist. - -- [ ] **Step 4: Refactor Claude Code `retrieve.py`** - -Modify imports: - -```python -from pathlib import Path -from lib.identity import project_slug, resolve_identity -from lib.prompt_context import build_prompt_context -``` - -Extract the current `main()` body into: - -```python -def handle_user_prompt_submit(hook_input): - config = load_config() - session_id = hook_input.get("session_id") or "unknown-session" - prompt = hook_input.get("prompt") or "" - hook_input["source_client"] = config.get("sourceClient") or "claude-code" - with SessionStateStore(state_root()).locked(session_id) as state: - turn_id, turn_seq = state.start_agent_turn(session_id) - seq = state.next_agent_seq() - state.append_agent_event( - normalize_user_prompt_event( - hook_input, seq, turn_id=turn_id, turn_seq=turn_seq - ) - ) - - if not config.get("autoRetrieve", True): - return {"continue": True} - if not config.get("autoPromptContext", False): - return {"continue": True} - - identity = resolve_identity(config, hook_input) - context_turns = int(config.get("retrieveContextTurns", 0)) - recent_context = read_recent_context(hook_input.get("transcript_path"), context_turns) - query = prompt if not recent_context else f"{recent_context}\ncurrent: {prompt}" - cwd = hook_input.get("cwd") or os.getcwd() - slug = project_slug(Path(cwd)) - client = MemindClient(config["memindApiUrl"], config.get("memindApiToken"), timeout=12, max_retries=0) - result = build_prompt_context(client, identity, query, slug, config) - context = _format_context(result, config) - if not context: - return {"continue": True} - return {"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": context}} -``` - -Then simplify `main()`: - -```python -def main(): - try: - hook_input = json.loads(sys.stdin.read() or "{}") - print(json.dumps(handle_user_prompt_submit(hook_input))) - except Exception as exc: - try: - debug_log(load_config(), "retrieve_failed", {"error": str(exc)}) - except Exception: - pass - print(json.dumps({"continue": True})) -``` - -- [ ] **Step 5: Refactor Codex `retrieve.py`** - -Modify imports: - -```python -from pathlib import Path -from lib.identity import project_slug, resolve_identity -from lib.prompt_context import build_prompt_context -``` - -Extract the current `main()` body into: - -```python -def handle_user_prompt_submit(hook_input): - config = load_config() - prompt = hook_input.get("prompt") or hook_input.get("user_prompt") or "" - hook_input["source_client"] = config.get("sourceClient") or "codex" - session_key = state_key(hook_input) - with SessionStateStore(state_root()).locked(session_key) as state: - turn_id, turn_seq = state.start_agent_turn(session_key) - seq = state.next_agent_seq() - state.append_agent_event( - normalize_user_prompt_event( - hook_input, seq, turn_id=turn_id, turn_seq=turn_seq - ) - ) - - if not config.get("autoRetrieve", True): - return {"continue": True} - if not config.get("autoPromptContext", False): - return {"continue": True} - - identity = resolve_identity(config, hook_input) - context_turns = int(config.get("retrieveContextTurns", 0)) - recent_context = read_recent_context(hook_input.get("transcript_path"), context_turns) - query = prompt if not recent_context else f"{recent_context}\ncurrent: {prompt}" - cwd = hook_input.get("cwd") or os.getcwd() - slug = project_slug(Path(cwd)) - client = MemindClient(config["memindApiUrl"], config.get("memindApiToken"), timeout=12, max_retries=0) - result = build_prompt_context(client, identity, query, slug, config) - context = _format_context(result, config) - if not context: - return {"continue": True} - return {"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": context}} -``` - -Then simplify `main()`: - -```python -def main(): - try: - hook_input = json.loads(sys.stdin.read() or "{}") - print(json.dumps(handle_user_prompt_submit(hook_input))) - except Exception as exc: - try: - debug_log(load_config(), "retrieve_failed", {"error": str(exc)}) - except Exception: - pass - print(json.dumps({"continue": True})) -``` - -- [ ] **Step 6: Run hook tests** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_hooks.py \ - memind-integrations/codex/tests/test_hooks.py -``` - -Expected: pass. - -- [ ] **Step 7: Commit** - -```bash -git add \ - memind-integrations/claude-code/scripts/retrieve.py \ - memind-integrations/claude-code/tests/test_hooks.py \ - memind-integrations/codex/scripts/retrieve.py \ - memind-integrations/codex/tests/test_hooks.py -git commit -m "feat(agent): gate prompt context injection" -``` - -## Task 5: Update Documentation - -**Files:** - -- Modify `memind-integrations/claude-code/README.md` -- Modify `memind-integrations/codex/README.md` - -- [ ] **Step 1: Update capability summary** - -In both READMEs, change text that currently says retrieval happens before each prompt by default. Use this wording: - -```markdown -- **Prompt context (optional)**: `UserPromptSubmit` always buffers the user prompt into the local agent timeline. If `autoPromptContext=true`, it also retrieves project-first Memind memories with a bounded global fallback and injects them as `...`. -``` - -- [ ] **Step 2: Update hook table** - -Change `UserPromptSubmit` description to: - -```markdown -| `UserPromptSubmit` | `scripts/retrieve.py` | 12s | Buffer the user prompt event. Optionally inject project-first prompt memory when `autoPromptContext=true`. | -``` - -- [ ] **Step 3: Update settings table** - -Add rows: - -```markdown -| `autoPromptContext` | `false` | Enables prompt-time `` retrieval and injection on `UserPromptSubmit`. Off by default to avoid token cost and unrelated cross-project recall. | -| `promptContextProjectMinEntries` | `4` | Minimum current-project entries before global fallback is skipped. | -| `promptContextGlobalFallbackEntries` | `3` | Maximum fallback entries from the shared memory space when current-project results are sparse. | -| `promptContextGlobalFallbackMinScore` | `0.65` | Minimum score for fallback entries. | -``` - -Update `autoRetrieve` row: - -```markdown -| `autoRetrieve` | `true` | Backward-compatible broad retrieval gate used by prompt and tool retrieval paths. Leave enabled unless you want to disable retrieval-assisted contexts entirely. | -``` - -- [ ] **Step 4: Update examples** - -Add an example: - -```json -{ - "autoPromptContext": true, - "promptContextProjectMinEntries": 4, - "promptContextGlobalFallbackEntries": 3, - "promptContextGlobalFallbackMinScore": 0.65 -} -``` - -Add example rendered context: - -```xml - -Relevant memories from Memind. Use only when directly helpful: - -## Directives -- [item:dir-1 directive, project, 2026-05-27] Keep userId and agentId stable across Claude Code, Codex, and API clients. -- [item:beh-1 behavior, global, 2026-05-20] User prefers Chinese replies for technical discussions. - -## Tool Notes -- [item:tool-9 tool, shared, 2026-05-18] After hook integration changes, run both Claude Code and Codex integration tests. - -``` - -- [ ] **Step 5: Run docs grep sanity** - -Run: - -```bash -rg -n "autoPromptContext|promptContextProjectMinEntries|Prompt context \\(optional\\)|project-first" \ - memind-integrations/claude-code/README.md \ - memind-integrations/codex/README.md -``` - -Expected: both READMEs mention the new settings and opt-in behavior. - -- [ ] **Step 6: Commit** - -```bash -git add \ - memind-integrations/claude-code/README.md \ - memind-integrations/codex/README.md -git commit -m "docs(agent): document opt-in prompt context" -``` - -## Task 6: Full Verification - -**Files:** - -- No new source files beyond previous tasks. - -- [ ] **Step 1: Run Claude Code integration tests** - -Run: - -```bash -python3 -m unittest discover memind-integrations/claude-code/tests -``` - -Expected: all Claude Code tests pass. - -- [ ] **Step 2: Run Codex integration tests** - -Run: - -```bash -python3 -m unittest discover memind-integrations/codex/tests -``` - -Expected: all Codex tests pass. - -- [ ] **Step 3: Run targeted prompt context tests** - -Run: - -```bash -python3 -m unittest \ - memind-integrations/claude-code/tests/test_prompt_context.py \ - memind-integrations/claude-code/tests/test_hooks.py \ - memind-integrations/claude-code/tests/test_context_compiler.py \ - memind-integrations/codex/tests/test_prompt_context.py \ - memind-integrations/codex/tests/test_hooks.py \ - memind-integrations/codex/tests/test_context_compiler.py -``` - -Expected: all pass. - -- [ ] **Step 4: Check whitespace** - -Run: - -```bash -git diff --check -``` - -Expected: no output. - -- [ ] **Step 5: Confirm no generated caches are staged** - -Run: - -```bash -git status --short -``` - -Expected: - -- Source/test/doc changes are staged or committed. -- `__pycache__` directories are not staged. - -- [ ] **Step 6: Push branch** - -Run: - -```bash -git push -``` - -Expected: remote branch updates successfully. - -## Risks And Mitigations - -Risk: `autoRetrieve` naming remains confusing. - -Mitigation: Keep it only for compatibility in this plan, document it as a broad legacy gate, and use `autoPromptContext`, `autoSessionContext`, and `autoToolContext` for precise user-facing behavior. - -Risk: Global fallback may surface unrelated project memories. - -Mitigation: Fallback is skipped when project results are sufficient, capped to three entries, score-filtered, deduped, and rendered with `global`/`shared` provenance labels. - -Risk: Some retrieve responses may lack score fields. - -Mitigation: Apply score thresholds only when a score field is present. Missing-score fallback entries keep Memind's returned order and are still capped by `promptContextGlobalFallbackEntries`. This preserves `RetrievedInsight` fallback results because the current Python client insight model does not expose scores. - -Risk: Code duplication between Claude Code and Codex. - -Mitigation: Keep files intentionally mirrored because the integrations are currently packaged separately. Tests must be added to both sides so future divergence is visible. - -Risk: Prompt context disabled by default may surprise users who read older docs. - -Mitigation: Update README language and settings tables to make SessionStart and PreToolUse the default automatic context paths, while prompt context is an explicit opt-in for users who want query-aware recall on every prompt. - -## Expected User-Facing Result - -Default behavior: - -- New session receives `` when available. -- Tool calls receive `` when exact file/tool context exists. -- Each user prompt is buffered into rawdata-agent timeline. -- No `` is injected on every user prompt unless enabled. - -Opt-in behavior: - -- `autoPromptContext=true` enables query-aware prompt recall. -- The first retrieve is current-project constrained. -- If current-project memory is sparse, Memind adds high-score reusable memories from the shared `userId + agentId` memory space. -- Injected memories carry provenance labels so the coding agent can prioritize project memory and treat shared/global memory as reusable guidance, not current-repo fact. - -## Self-Review - -Spec coverage: - -- Default prompt injection disabled: Task 1 and Task 4. -- UserPromptSubmit still buffers prompt: Task 4 tests and implementation. -- Project-aware exact filtering: Task 2 helper. -- Project-first plus global fallback: Task 2 helper and tests. -- Not project-only: Task 2 fallback uses no project metadata filter. -- Source labels: Task 3. -- Claude Code and Codex parity: every task touches both integrations. -- No core/OpenAPI changes: file map and non-goals constrain the work to integration code. - -Placeholder scan: - -- No TODO/TBD placeholders. -- Every implementation task names concrete files, commands, and expected outcomes. - -Type consistency: - -- New setting name is consistently `autoPromptContext`. -- New helper name is consistently `build_prompt_context`. -- New provenance field is consistently `memindContextSource`. -- Existing wrapper remains `memind_memories`. diff --git a/docs/superpowers/plans/2026-05-28-rawdata-agent-toolcall-telemetry.md b/docs/superpowers/plans/2026-05-28-rawdata-agent-toolcall-telemetry.md deleted file mode 100644 index cf397ae9..00000000 --- a/docs/superpowers/plans/2026-05-28-rawdata-agent-toolcall-telemetry.md +++ /dev/null @@ -1,1239 +0,0 @@ -# RawData Agent ToolCall Telemetry Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Absorb the useful deterministic parts of `rawdata-toolcall` into `rawdata-agent` so Claude Code and Codex timelines produce richer file/tool-aware evidence without adding extra LLM calls or duplicating raw data paths. - -**Architecture:** Claude Code and Codex continue to ingest only `agent_timeline` raw data. Tool-call telemetry is normalized at hook time, preserved as typed `AgentEvent` fields, aggregated into agent episode segment metadata, and used to improve deterministic `tool` items. `rawdata-toolcall` remains a separate compatibility plugin for pure tool logs. - -**Tech Stack:** Python integration hooks, Java 21 records, JUnit 5, AssertJ, Maven, Memind rawdata plugin architecture. - ---- - -## Scope And Non-Goals - -This plan implements only deterministic tool telemetry absorption into `rawdata-agent`. - -In scope: - -- Capture `durationMs`, `inputTokens`, `outputTokens`, and `contentHash` in Claude Code and Codex normalized tool events when hook payloads expose them. -- Use redacted/canonicalized semantic tool input and output to compute `contentHash`; exclude volatile telemetry fields such as duration and token usage from the hash. -- Add `inputTokens`, `outputTokens`, and `contentHash` to `AgentEvent`; reuse existing `occurredAt` as the tool call timestamp and existing `durationMs` as duration. -- Add capped `toolRecords`, `toolStats`, and `toolGroups` metadata to formatted `agent_episode` segments. -- Copy useful tool telemetry metadata into deterministic `category=tool` and `category=resolution` memory items. -- Keep the existing `agent_timeline` caption and item extraction LLM flow unchanged. - -Out of scope: - -- No `rawdata-toolcall` LLM extraction inside `rawdata-agent`. -- No `ToolCallChunker` by-tool segmentation for `agent_timeline`. -- No Claude Code/Codex double-ingestion into both `rawdata-agent` and `rawdata-toolcall`. -- No PreToolUse file context query/injection implementation in this plan. -- No OpenAPI metadata filter changes in this plan. - -## File Map - -- Modify `memind-integrations/claude-code/scripts/lib/agent_timeline.py` - - Extract optional telemetry fields from hook payloads. - - Compute `contentHash` from redacted canonical tool content. - - Keep redaction before hashing. - -- Modify `memind-integrations/codex/scripts/lib/agent_timeline.py` - - Mirror Claude Code normalization behavior. - -- Modify `memind-integrations/claude-code/tests/test_agent_timeline.py` - - Cover telemetry normalization and secret-safe hash behavior. - -- Modify `memind-integrations/codex/tests/test_agent_timeline.py` - - Mirror Claude Code tests with Codex source client. - -- Modify `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEvent.java` - - Add typed `inputTokens`, `outputTokens`, and `contentHash` fields. - -- Modify `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java` - - Include new typed telemetry fields in canonical event identity. - -- Modify `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactor.java` - - Preserve the new typed fields after redaction. - -- Create `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentToolTelemetry.java` - - Build capped `toolRecords`, `toolStats`, and `toolGroups` metadata from episode events. - -- Modify `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java` - - Add telemetry metadata to agent episode segments. - -- Modify `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java` - - Copy telemetry metadata into deterministic item metadata. - - Improve deterministic tool item text when validation failed and then passed. - -- Modify Java tests under `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/...` - - Add serialization, redaction, formatter, and deterministic item tests. - -- Modify `memind-integrations/claude-code/README.md` and `memind-integrations/codex/README.md` - - Document the strengthened `rawdata-agent` tool telemetry path and clarify that `rawdata-toolcall` remains pure-tool-log compatibility. - ---- - -### Task 1: Add Hook Telemetry Tests For Claude Code And Codex - -**Files:** -- Modify: `memind-integrations/claude-code/tests/test_agent_timeline.py` -- Modify: `memind-integrations/codex/tests/test_agent_timeline.py` - -- [ ] **Step 1: Add a Claude Code telemetry normalization test** - -Add this test method to `AgentTimelineTest` in `memind-integrations/claude-code/tests/test_agent_timeline.py`: - -```python -def test_normalizes_tool_telemetry_and_content_hash(self): - event = normalize_hook_event( - { - "hook_event_name": "PostToolUse", - "session_id": "s", - "tool_name": "Bash", - "tool_input": {"command": "npm test payment"}, - "tool_response": { - "exit_code": 0, - "stdout": "passed", - "duration_ms": 1234, - "usage": {"input_tokens": 11, "output_tokens": 22}, - }, - "timestamp": "2026-05-24T10:00:00Z", - }, - seq=1, - ) - - self.assertEqual(event["durationMs"], 1234) - self.assertEqual(event["inputTokens"], 11) - self.assertEqual(event["outputTokens"], 22) - self.assertTrue(event["contentHash"].startswith("sha256:")) - self.assertEqual(len(event["contentHash"]), len("sha256:") + 64) - self.assertEqual(event["output"], '{"stdout": "passed"}') - self.assertEqual(event["metadata"]["normalizationVersion"], 1) -``` - -- [ ] **Step 2: Add a Claude Code secret-safe hash test** - -Add this test method to the same class: - -```python -def test_content_hash_uses_redacted_payload(self): - first = normalize_hook_event( - { - "hook_event_name": "PostToolUse", - "session_id": "s", - "tool_name": "CustomTool", - "tool_input": {"token": "Bearer first-secret-value"}, - "tool_response": {"result": "ok"}, - "timestamp": "2026-05-24T10:00:00Z", - }, - seq=1, - ) - second = normalize_hook_event( - { - "hook_event_name": "PostToolUse", - "session_id": "s", - "tool_name": "CustomTool", - "tool_input": {"token": "Bearer second-secret-value"}, - "tool_response": {"result": "ok"}, - "timestamp": "2026-05-24T10:00:01Z", - }, - seq=2, - ) - - self.assertEqual(first["contentHash"], second["contentHash"]) - self.assertIn("[REDACTED:bearer_token]", first["input"]) - self.assertIn("[REDACTED:bearer_token]", second["input"]) -``` - -- [ ] **Step 3: Add a Claude Code telemetry-stable hash test** - -Add this test method to the same class: - -```python -def test_content_hash_ignores_volatile_telemetry_fields(self): - first = normalize_hook_event( - { - "hook_event_name": "PostToolUse", - "session_id": "s", - "tool_name": "Bash", - "tool_input": {"command": "npm test payment"}, - "tool_response": { - "exit_code": 0, - "stdout": "passed", - "duration_ms": 100, - "metadata": {"duration_ms": 100}, - "usage": {"input_tokens": 11, "output_tokens": 22}, - }, - "timestamp": "2026-05-24T10:00:00Z", - }, - seq=1, - ) - second = normalize_hook_event( - { - "hook_event_name": "PostToolUse", - "session_id": "s", - "tool_name": "Bash", - "tool_input": {"command": "npm test payment"}, - "tool_response": { - "exit_code": 0, - "stdout": "passed", - "duration_ms": 999, - "metadata": {"duration_ms": 999}, - "usage": {"input_tokens": 100, "output_tokens": 200}, - }, - "timestamp": "2026-05-24T10:00:01Z", - }, - seq=2, - ) - - self.assertEqual(first["contentHash"], second["contentHash"]) - self.assertNotIn("duration_ms", first.get("output", "")) - self.assertNotIn("metadata", first.get("output", "")) - self.assertNotIn("usage", first.get("output", "")) -``` - -- [ ] **Step 4: Add equivalent Codex tests** - -Add the same three tests to `memind-integrations/codex/tests/test_agent_timeline.py`, with `"source_client": "codex"` in each hook input. Assert `event["metadata"]["sourceClient"] == "codex"` in the first test. - -- [ ] **Step 5: Run Python tests and verify failure** - -Run: - -```bash -python3 -m unittest memind-integrations/claude-code/tests/test_agent_timeline.py memind-integrations/codex/tests/test_agent_timeline.py -``` - -Expected: FAIL because `durationMs`, `inputTokens`, `outputTokens`, or `contentHash` are not emitted yet. - ---- - -### Task 2: Implement Hook Telemetry Normalization - -**Files:** -- Modify: `memind-integrations/claude-code/scripts/lib/agent_timeline.py` -- Modify: `memind-integrations/codex/scripts/lib/agent_timeline.py` - -- [ ] **Step 1: Add telemetry helpers to Claude Code normalizer** - -Add these helpers near `_json_text` in `memind-integrations/claude-code/scripts/lib/agent_timeline.py`: - -```python -def _number_value(*values): - for value in values: - if isinstance(value, bool): - continue - if isinstance(value, int): - return value - if isinstance(value, float): - return int(value) - if isinstance(value, str) and value.strip().isdigit(): - return int(value.strip()) - return None - - -def _nested_number(mapping, *path): - current = mapping - for key in path: - if not isinstance(current, dict): - return None - current = current.get(key) - return _number_value(current) - - -def _tool_telemetry(hook_input, tool_response): - usage = tool_response.get("usage") if isinstance(tool_response, dict) else {} - return { - "durationMs": _number_value( - hook_input.get("duration_ms"), - hook_input.get("durationMs"), - tool_response.get("duration_ms") if isinstance(tool_response, dict) else None, - tool_response.get("durationMs") if isinstance(tool_response, dict) else None, - _nested_number(tool_response, "metadata", "duration_ms"), - _nested_number(tool_response, "metadata", "durationMs"), - ), - "inputTokens": _number_value( - hook_input.get("input_tokens"), - hook_input.get("inputTokens"), - tool_response.get("input_tokens") if isinstance(tool_response, dict) else None, - tool_response.get("inputTokens") if isinstance(tool_response, dict) else None, - usage.get("input_tokens") if isinstance(usage, dict) else None, - usage.get("inputTokens") if isinstance(usage, dict) else None, - ), - "outputTokens": _number_value( - hook_input.get("output_tokens"), - hook_input.get("outputTokens"), - tool_response.get("output_tokens") if isinstance(tool_response, dict) else None, - tool_response.get("outputTokens") if isinstance(tool_response, dict) else None, - usage.get("output_tokens") if isinstance(usage, dict) else None, - usage.get("outputTokens") if isinstance(usage, dict) else None, - ), - } - - -VOLATILE_TOOL_OUTPUT_KEYS = { - "exit_code", - "exitCode", - "duration_ms", - "durationMs", - "input_tokens", - "inputTokens", - "output_tokens", - "outputTokens", - "usage", -} - - -def _semantic_tool_output(raw_tool_response): - if isinstance(raw_tool_response, list): - return [_semantic_tool_output(value) for value in raw_tool_response] - if not isinstance(raw_tool_response, dict): - return raw_tool_response - result = {} - for key, value in raw_tool_response.items(): - if key in VOLATILE_TOOL_OUTPUT_KEYS or value is None: - continue - normalized = _semantic_tool_output(value) - if normalized not in (None, {}, []): - result[key] = normalized - return result - - -def _content_hash(tool_name, normalized_input, normalized_output): - stable = json.dumps( - { - "toolName": tool_name or "", - "input": normalized_input or "", - "output": normalized_output or "", - }, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - ) - return "sha256:" + hashlib.sha256(stable.encode("utf-8")).hexdigest() -``` - -- [ ] **Step 2: Emit telemetry in `normalize_hook_event`** - -In `normalize_hook_event`, use `_semantic_tool_output(raw_tool_response)` when building `output` so typed telemetry does not duplicate into output text or destabilize `contentHash`: - -```python - output = _semantic_tool_output(raw_tool_response) -``` - -After redacted input/output values are computed and before metadata is assigned, set top-level telemetry fields: - -```python - telemetry = _tool_telemetry(hook_input, tool_response) - if telemetry.get("durationMs") is not None: - event["durationMs"] = telemetry["durationMs"] - if telemetry.get("inputTokens") is not None: - event["inputTokens"] = telemetry["inputTokens"] - if telemetry.get("outputTokens") is not None: - event["outputTokens"] = telemetry["outputTokens"] - event["contentHash"] = _content_hash( - tool_name, - event.get("command") if event.get("command") is not None else event.get("input"), - event.get("output"), - ) -``` - -Keep `contentHash` based on redacted values already stored on `event`; do not hash raw input/output. - -- [ ] **Step 3: Mirror the same changes in Codex normalizer** - -Apply the same helper methods and `normalize_hook_event` update to `memind-integrations/codex/scripts/lib/agent_timeline.py`. - -- [ ] **Step 4: Run Python tests and verify pass** - -Run: - -```bash -python3 -m unittest memind-integrations/claude-code/tests/test_agent_timeline.py memind-integrations/codex/tests/test_agent_timeline.py -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -Run: - -```bash -git add memind-integrations/claude-code/scripts/lib/agent_timeline.py memind-integrations/codex/scripts/lib/agent_timeline.py memind-integrations/claude-code/tests/test_agent_timeline.py memind-integrations/codex/tests/test_agent_timeline.py -git commit -m "feat(agent): capture tool telemetry in timeline hooks" -``` - ---- - -### Task 3: Add AgentEvent Typed Telemetry Fields - -**Files:** -- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEvent.java` -- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java` -- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactor.java` -- Modify Java tests constructing `new AgentEvent(...)` -- Test: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContentTest.java` -- Test: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactorTest.java` - -- [ ] **Step 1: Add a failing Jackson preservation test** - -Add this test to `AgentTimelineContentTest`: - -```java -@Test -void jacksonShouldPreserveToolTelemetryFields() throws Exception { - String json = - """ - { - "type": "agent_timeline", - "sourceClient": "claude-code", - "sessionId": "session-1", - "agentTurnId": "turn-1", - "timelineId": "timeline-1", - "events": [ - { - "eventId": "event-tool", - "seq": 1, - "kind": "command", - "toolName": "Bash", - "command": "npm test payment", - "status": "success", - "durationMs": 1234, - "inputTokens": 11, - "outputTokens": 22, - "contentHash": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - } - ] - } - """; - - RawContent decoded = OBJECT_MAPPER.readValue(json, RawContent.class); - - AgentEvent event = ((AgentTimelineContent) decoded).events().getFirst(); - assertThat(event.durationMs()).isEqualTo(1234L); - assertThat(event.inputTokens()).isEqualTo(11); - assertThat(event.outputTokens()).isEqualTo(22); - assertThat(event.contentHash()) - .isEqualTo("sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); -} -``` - -- [ ] **Step 2: Add a failing canonical identity test** - -Add this test to `AgentTimelineContentTest`: - -```java -@Test -void contentIdShouldIncludeTypedToolTelemetryFields() { - AgentProject project = new AgentProject("payment-service", "/repo/payment", null, Map.of()); - AgentEvent first = - new AgentEvent( - "e1", - 1, - AgentEventKind.COMMAND, - Instant.parse("2026-05-24T10:00:00Z"), - null, - "Bash", - null, - "passed", - AgentEventStatus.SUCCESS, - 1234L, - 11, - 22, - "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - null, - "run", - "npm test payment", - 0, - Map.of()); - AgentEvent second = - new AgentEvent( - "e1", - 1, - AgentEventKind.COMMAND, - Instant.parse("2026-05-24T10:00:00Z"), - null, - "Bash", - null, - "passed", - AgentEventStatus.SUCCESS, - 1234L, - 11, - 23, - "sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", - null, - "run", - "npm test payment", - 0, - Map.of()); - - AgentTimelineContent firstContent = - new AgentTimelineContent( - "claude-code", - "1.0", - "session-123", - "turn-1", - "timeline-1", - project, - List.of(first)); - AgentTimelineContent secondContent = - new AgentTimelineContent( - "claude-code", - "1.0", - "session-123", - "turn-1", - "timeline-1", - project, - List.of(second)); - - assertThat(firstContent.getContentId()).isNotEqualTo(secondContent.getContentId()); -} -``` - -- [ ] **Step 3: Run the Java test and verify failure** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentTimelineContentTest test -``` - -Expected: FAIL because `inputTokens`, `outputTokens`, and `contentHash` are not typed fields yet. - -- [ ] **Step 4: Update `AgentEvent` record** - -Change `AgentEvent` to include the new fields immediately after `durationMs`: - -```java -public record AgentEvent( - String eventId, - Integer seq, - AgentEventKind kind, - Instant occurredAt, - String text, - String toolName, - String input, - String output, - AgentEventStatus status, - Long durationMs, - Integer inputTokens, - Integer outputTokens, - String contentHash, - String path, - String operation, - String command, - Integer exitCode, - Map metadata) { -``` - -- [ ] **Step 5: Include telemetry fields in canonical event identity** - -In `AgentTimelineContent.canonicalEvent`, include the new fields immediately after `durationMs`: - -```java - normalized(event.durationMs()), - normalized(event.inputTokens()), - normalized(event.outputTokens()), - normalized(event.contentHash()), - normalized(event.path()), -``` - -- [ ] **Step 6: Preserve fields in `AgentEventRedactor`** - -Update the `new AgentEvent(...)` call in `AgentEventRedactor.redact` to pass the new fields: - -```java - event.durationMs(), - event.inputTokens(), - event.outputTokens(), - event.contentHash(), - event.path(), -``` - -- [ ] **Step 7: Update all test constructors** - -Every existing `new AgentEvent(...)` call that already passes `durationMs` must insert the three new fields immediately after that `durationMs` value: - -```java -null, -null, -null, -``` - -For telemetry-specific constructors, keep the existing duration value and then pass concrete telemetry values: - -```java -1234L, -11, -22, -"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", -``` - -- [ ] **Step 8: Run Java tests and verify pass** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentTimelineContentTest,AgentEventRedactorTest test -``` - -Expected: PASS. - -- [ ] **Step 9: Commit** - -Run: - -```bash -git add memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/model/AgentEvent.java memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/content/AgentTimelineContent.java memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactor.java memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent -git commit -m "feat(agent): add typed tool telemetry fields" -``` - ---- - -### Task 4: Add Deterministic Tool Telemetry Aggregation - -**Files:** -- Create: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentToolTelemetry.java` -- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatter.java` -- Test: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java` - -- [ ] **Step 1: Add a failing segment metadata test** - -Extend `shouldFormatEpisodeTextAndMetadataDeterministically` in `AgentSegmentFormatterTest` with assertions: - -```java -assertThat(formatted.metadata().get("toolRecords")) - .asList() - .contains( - Map.of( - "eventId", - "e2", - "seq", - 2, - "toolName", - "Bash", - "kind", - "command", - "status", - "failed", - "durationMs", - 10L, - "command", - "npm test payment", - "outputPreview", - "rounding mismatch")); -assertThat(formatted.metadata().get("toolStats")) - .isEqualTo( - Map.of( - "Bash", - Map.of( - "callCount", - 2, - "successCount", - 1, - "failCount", - 1, - "avgDurationMs", - 10L), - "Edit", - Map.of( - "callCount", - 1, - "successCount", - 1, - "failCount", - 0, - "avgDurationMs", - 10L))); -assertThat(formatted.metadata().get("toolGroups")) - .asList() - .contains( - Map.of( - "toolName", - "Bash", - "callCount", - 2, - "successCount", - 1, - "failCount", - 1, - "commands", - List.of("npm test payment"))); -``` - -- [ ] **Step 2: Run formatter test and verify failure** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentSegmentFormatterTest test -``` - -Expected: FAIL because `toolRecords`, `toolStats`, and `toolGroups` are absent. - -- [ ] **Step 3: Create `AgentToolTelemetry`** - -Create `AgentToolTelemetry.java`: - -```java -package com.openmemind.ai.memory.plugin.rawdata.agent.chunk; - -import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEvent; -import com.openmemind.ai.memory.plugin.rawdata.agent.model.AgentEventStatus; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; - -final class AgentToolTelemetry { - - private static final int MAX_TOOL_RECORDS = 40; - private static final int MAX_TOOL_STATS = 20; - private static final int MAX_TOOL_GROUPS = 20; - private static final int MAX_GROUP_VALUES = 20; - private static final int MAX_OUTPUT_PREVIEW_CHARS = 240; - - private AgentToolTelemetry() {} - - static Map metadata(List events) { - List toolEvents = - events == null - ? List.of() - : events.stream().filter(AgentToolTelemetry::hasToolEvidence).toList(); - if (toolEvents.isEmpty()) { - return Map.of(); - } - var metadata = new LinkedHashMap(); - metadata.put("toolRecords", toolRecords(toolEvents)); - metadata.put("toolStats", toolStats(toolEvents)); - metadata.put("toolGroups", toolGroups(toolEvents)); - return Map.copyOf(metadata); - } - - private static boolean hasToolEvidence(AgentEvent event) { - return event != null && event.toolName() != null && !event.toolName().isBlank(); - } - - private static List> toolRecords(List events) { - var records = new ArrayList>(); - for (AgentEvent event : events.stream().limit(MAX_TOOL_RECORDS).toList()) { - var record = new LinkedHashMap(); - put(record, "eventId", event.eventId()); - put(record, "seq", event.seq()); - put(record, "toolName", event.toolName()); - put(record, "kind", event.kind() == null ? null : event.kind().wireValue()); - put(record, "status", event.status() == null ? null : event.status().wireValue()); - put(record, "durationMs", event.durationMs()); - put(record, "inputTokens", event.inputTokens()); - put(record, "outputTokens", event.outputTokens()); - put(record, "contentHash", event.contentHash()); - put(record, "path", event.path()); - put(record, "operation", event.operation()); - put(record, "command", event.command()); - put(record, "outputPreview", concise(event.output())); - records.add(Map.copyOf(record)); - } - return List.copyOf(records); - } - - private static Map> toolStats(List events) { - var grouped = new LinkedHashMap>(); - events.forEach(event -> grouped.computeIfAbsent(event.toolName(), key -> new ArrayList<>()).add(event)); - var stats = new LinkedHashMap>(); - grouped.entrySet().stream() - .limit(MAX_TOOL_STATS) - .forEach(groupEntry -> { - String toolName = groupEntry.getKey(); - List toolEvents = groupEntry.getValue(); - int success = countStatus(toolEvents, AgentEventStatus.SUCCESS); - int failed = countStatus(toolEvents, AgentEventStatus.FAILED); - var durations = - toolEvents.stream() - .map(AgentEvent::durationMs) - .filter(value -> value != null) - .mapToLong(Long::longValue) - .summaryStatistics(); - var stat = new LinkedHashMap(); - stat.put("callCount", toolEvents.size()); - stat.put("successCount", success); - stat.put("failCount", failed); - if (durations.getCount() > 0) { - stat.put("avgDurationMs", Math.round(durations.getAverage())); - } - sum(toolEvents, AgentEvent::inputTokens).ifPresent(value -> stat.put("inputTokens", value)); - sum(toolEvents, AgentEvent::outputTokens).ifPresent(value -> stat.put("outputTokens", value)); - stats.put(toolName, Map.copyOf(stat)); - }); - return Map.copyOf(stats); - } - - private static List> toolGroups(List events) { - var grouped = new LinkedHashMap>(); - events.forEach(event -> grouped.computeIfAbsent(event.toolName(), key -> new ArrayList<>()).add(event)); - var groups = new ArrayList>(); - grouped.entrySet().stream() - .limit(MAX_TOOL_GROUPS) - .forEach(entry -> { - String toolName = entry.getKey(); - List toolEvents = entry.getValue(); - var commands = new LinkedHashSet(); - var paths = new LinkedHashSet(); - toolEvents.stream() - .map(AgentEvent::command) - .filter(AgentToolTelemetry::hasText) - .limit(MAX_GROUP_VALUES) - .forEach(commands::add); - toolEvents.stream() - .map(AgentEvent::path) - .filter(AgentToolTelemetry::hasText) - .limit(MAX_GROUP_VALUES) - .forEach(paths::add); - var group = new LinkedHashMap(); - group.put("toolName", toolName); - group.put("callCount", toolEvents.size()); - group.put("successCount", countStatus(toolEvents, AgentEventStatus.SUCCESS)); - group.put("failCount", countStatus(toolEvents, AgentEventStatus.FAILED)); - if (!commands.isEmpty()) { - group.put("commands", List.copyOf(commands)); - } - if (!paths.isEmpty()) { - group.put("paths", List.copyOf(paths)); - } - groups.add(Map.copyOf(group)); - }); - return List.copyOf(groups); - } - - private static int countStatus(List events, AgentEventStatus status) { - return (int) events.stream().filter(event -> event.status() == status).count(); - } - - private static java.util.Optional sum( - List events, java.util.function.Function getter) { - var values = events.stream().map(getter).filter(value -> value != null).toList(); - if (values.isEmpty()) { - return java.util.Optional.empty(); - } - return java.util.Optional.of(values.stream().mapToInt(Integer::intValue).sum()); - } - - private static void put(Map target, String key, Object value) { - if (value != null && (!(value instanceof String text) || !text.isBlank())) { - target.put(key, value); - } - } - - private static String concise(String value) { - if (!hasText(value)) { - return null; - } - String normalized = value.replaceAll("\\s+", " ").trim(); - return normalized.length() <= MAX_OUTPUT_PREVIEW_CHARS - ? normalized - : normalized.substring(0, MAX_OUTPUT_PREVIEW_CHARS); - } - - private static boolean hasText(String value) { - return value != null && !value.isBlank(); - } -} -``` - -- [ ] **Step 4: Add telemetry metadata in `AgentSegmentFormatter`** - -In `metadata(AgentTimelineContent timeline, AgentEpisode episode)`, after `fileEvents` is added, merge telemetry metadata: - -```java -metadata.putAll(AgentToolTelemetry.metadata(episode.events())); -``` - -- [ ] **Step 5: Run formatter test and verify pass** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentSegmentFormatterTest test -``` - -Expected: PASS. - -- [ ] **Step 6: Commit** - -Run: - -```bash -git add memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java -git commit -m "feat(agent): aggregate tool telemetry in episode metadata" -``` - ---- - -### Task 5: Improve Deterministic Tool Items With Telemetry - -**Files:** -- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java` -- Test: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java` - -- [ ] **Step 1: Add a failing deterministic item metadata test** - -In `AgentItemExtractionStrategyTest`, add these entries to the metadata returned by `successfulEpisode()`: - -```java -Map.entry( - "toolStats", - Map.of( - "Bash", - Map.of( - "callCount", - 2, - "successCount", - 1, - "failCount", - 1, - "avgDurationMs", - 10L))), -Map.entry( - "toolRecords", - List.of( - Map.of( - "eventId", - "e2", - "seq", - 2, - "toolName", - "Bash", - "status", - "failed", - "command", - "npm test payment", - "outputPreview", - "rounding mismatch"), - Map.of( - "eventId", - "e4", - "seq", - 4, - "toolName", - "Bash", - "status", - "success", - "command", - "npm test payment", - "outputPreview", - "passed"))), -Map.entry( - "toolGroups", - List.of( - Map.of( - "toolName", - "Bash", - "callCount", - 2, - "successCount", - 1, - "failCount", - 1, - "commands", - List.of("npm test payment")))) -``` - -Then assert those keys are copied to the produced `tool` item metadata: - -```java -assertThat(tool.metadata()) - .containsKeys("toolStats", "toolRecords", "toolGroups") - .containsEntry("command", "npm test payment") - .containsEntry("successCount", 1) - .containsEntry("failCount", 1); -``` - -- [ ] **Step 2: Add a failing deterministic tool content test** - -Assert that failed-then-passed validation produces a more informative tool item: - -```java -assertThat(tool.content()) - .contains("npm test payment") - .contains("failed once") - .contains("passed once") - .contains("src/payment/calc.ts"); -``` - -- [ ] **Step 3: Run item extraction test and verify failure** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentItemExtractionStrategyTest test -``` - -Expected: FAIL because telemetry metadata is not copied and the current tool content uses the simpler validation text. - -- [ ] **Step 4: Copy telemetry metadata into deterministic item metadata** - -In `baseMetadata(EpisodeMetadata episode)`, add: - -```java -copy(episode.raw(), metadata, "toolStats"); -copy(episode.raw(), metadata, "toolRecords"); -copy(episode.raw(), metadata, "toolGroups"); -``` - -- [ ] **Step 5: Improve `toolContent`** - -Replace the first `command != null && !episode.files().isEmpty()` branch with: - -```java -if (command != null && !episode.files().isEmpty()) { - String fileList = String.join(", ", episode.files()); - if (failCount > 0 || successCount > 0) { - return "Use %s to validate changes touching %s; it failed %s and passed %s in this agent episode." - .formatted(command, fileList, countWord(failCount), countWord(successCount)); - } - return "Use %s to validate changes touching %s.".formatted(command, fileList); -} -``` - -Keep existing branches for command-without-files and tool-without-command. - -- [ ] **Step 6: Run item extraction test and verify pass** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentItemExtractionStrategyTest test -``` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -Run: - -```bash -git add memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentMemoryItemFactory.java memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/item/AgentItemExtractionStrategyTest.java -git commit -m "feat(agent): enrich deterministic tool memories with telemetry" -``` - ---- - -### Task 6: Protect Privacy, Size, And Compatibility - -**Files:** -- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactorTest.java` -- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java` -- Modify: `memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentToolTelemetry.java` - -- [ ] **Step 1: Add redactor preservation test** - -Add this test to `AgentEventRedactorTest`: - -```java -@Test -void shouldPreserveToolTelemetryWhenRedactingText() { - AgentEvent event = - new AgentEvent( - "e1", - 1, - AgentEventKind.COMMAND, - Instant.parse("2026-05-24T10:00:00Z"), - null, - "Bash", - null, - "Bearer secret-token-value", - AgentEventStatus.SUCCESS, - 1234L, - 11, - 22, - "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - null, - null, - "npm test payment", - 0, - Map.of()); - - AgentEvent redacted = new AgentEventRedactor().redact(event); - - assertThat(redacted.durationMs()).isEqualTo(1234L); - assertThat(redacted.inputTokens()).isEqualTo(11); - assertThat(redacted.outputTokens()).isEqualTo(22); - assertThat(redacted.contentHash()).isEqualTo(event.contentHash()); - assertThat(redacted.output()).contains("[REDACTED:bearer_token]"); -} -``` - -- [ ] **Step 2: Add capped telemetry metadata tests** - -Add a test to `AgentSegmentFormatterTest` that builds 45 tool events and asserts: - -```java -assertThat(formatted.metadata().get("toolRecords")).asList().hasSize(40); -``` - -Build events with `AgentEpisodeTestSupport.event(...)` and distinct event IDs. - -Add a second test that builds 25 distinct tool names and asserts: - -```java -assertThat(((Map) formatted.metadata().get("toolStats"))).hasSize(20); -assertThat(formatted.metadata().get("toolGroups")).asList().hasSize(20); -``` - -In the same test or a focused helper test, include one tool with 25 distinct commands/paths and assert the first group caps both values: - -```java -Map group = (Map) ((List) formatted.metadata().get("toolGroups")).getFirst(); -assertThat(group.get("commands")).asList().hasSize(20); -assertThat(group.get("paths")).asList().hasSize(20); -``` - -- [ ] **Step 3: Run privacy and formatter tests** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -Dtest=AgentEventRedactorTest,AgentSegmentFormatterTest test -``` - -Expected: PASS. - -- [ ] **Step 4: Commit** - -Run: - -```bash -git add memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/privacy/AgentEventRedactorTest.java memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/test/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentSegmentFormatterTest.java memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent/src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/chunk/AgentToolTelemetry.java -git commit -m "test(agent): cover tool telemetry privacy and caps" -``` - ---- - -### Task 7: Document The RawData-Agent / RawData-ToolCall Boundary - -**Files:** -- Modify: `memind-integrations/claude-code/README.md` -- Modify: `memind-integrations/codex/README.md` -- Modify: `docs/superpowers/specs/2026-05-24-rawdata-agent-design.md` - -- [ ] **Step 1: Update Claude Code README** - -In the `rawdata-toolcall` relationship section, include this text: - -```markdown -`rawdata-agent` absorbs deterministic tool telemetry from the `rawdata-toolcall` design: duration, token counts, -content hashes, per-episode tool records, and per-tool success/failure stats. Claude Code still submits one canonical -`agent_timeline` per turn; it does not submit duplicate `tool_call` raw data. `rawdata-toolcall` remains the correct -entry point for pure tool-call logs that do not have user prompts, agent turns, or Stop boundaries. -``` - -- [ ] **Step 2: Update Codex README** - -Add the same boundary text to `memind-integrations/codex/README.md`. - -- [ ] **Step 3: Update design document relationship section** - -In `docs/superpowers/specs/2026-05-24-rawdata-agent-design.md`, refine the `rawdata-toolcall` relationship section to include: - -```markdown -`rawdata-toolcall` remains a separate raw data type for pure tool-call logs. `rawdata-agent` may reuse deterministic -tool telemetry ideas from `rawdata-toolcall`, but must not run the toolcall LLM extraction path by default and must not -double-ingest Claude Code or Codex tool activity. -``` - -- [ ] **Step 4: Commit** - -Run: - -```bash -git add memind-integrations/claude-code/README.md memind-integrations/codex/README.md docs/superpowers/specs/2026-05-24-rawdata-agent-design.md -git commit -m "docs(agent): clarify tool telemetry boundary" -``` - ---- - -### Task 8: Full Verification - -**Files:** -- No new files. - -- [ ] **Step 1: Run Python integration unit tests** - -Run: - -```bash -python3 -m unittest memind-integrations/claude-code/tests/test_agent_timeline.py memind-integrations/codex/tests/test_agent_timeline.py -``` - -Expected: PASS. - -- [ ] **Step 2: Run rawdata-agent Maven tests** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -am test -``` - -Expected: PASS. - -- [ ] **Step 3: Run formatting checks for touched Java module** - -Run: - -```bash -mvn -pl memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent spotless:check -``` - -Expected: PASS. - -- [ ] **Step 4: Inspect diff for accidental double ingestion** - -Run: - -```bash -git diff -- memind-integrations/claude-code/hooks/hooks.json memind-integrations/codex/hooks/hooks.json memind-integrations/claude-code/scripts memind-integrations/codex/scripts -``` - -Expected: no change that submits `tool_call` raw data from Claude Code or Codex. - -- [ ] **Step 5: Inspect rawdata-toolcall for unintended edits** - -Run: - -```bash -git diff -- memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-toolcall -``` - -Expected: empty diff unless documentation references were intentionally changed outside that module. - -- [ ] **Step 6: Commit verification-only fixes if formatting required changes** - -If formatting changes were applied by `spotless:apply`, commit them: - -```bash -git add memind-plugins/memind-plugin-rawdatas/memind-plugin-rawdata-agent -git commit -m "style(agent): format tool telemetry changes" -``` - -If no formatting changes were made, do not create a verification commit. - ---- - -## Expected Outcome - -After this plan is implemented: - -- Claude Code and Codex tool events carry stable tool telemetry into `agent_timeline`. -- `rawdata-agent` episode segments expose file/tool-aware evidence through `toolRecords`, `toolStats`, and `toolGroups`. -- Deterministic tool memories become more useful without increasing LLM calls. -- `rawdata-toolcall` remains valuable for pure tool logs and is not duplicated by Claude Code/Codex. -- The new metadata creates a stronger foundation for a later PreToolUse file-history context feature. - -## Self-Review Notes - -- Spec coverage: covers deterministic fields, redacted `contentHash`, episode metadata, deterministic item enrichment, documentation boundary, and verification. -- Placeholder scan: no deferred implementation markers are used. -- Type consistency: hook payload emits `durationMs`, `inputTokens`, `outputTokens`, `contentHash`; Java `AgentEvent` uses the same names; segment metadata uses `toolRecords`, `toolStats`, and `toolGroups`. -- Scope check: PreToolUse file context and OpenAPI file queries are explicitly excluded to keep this implementation focused and independently testable. diff --git a/docs/superpowers/specs/2026-05-24-rawdata-agent-design.md b/docs/superpowers/specs/2026-05-24-rawdata-agent-design.md deleted file mode 100644 index eb7d4958..00000000 --- a/docs/superpowers/specs/2026-05-24-rawdata-agent-design.md +++ /dev/null @@ -1,1420 +0,0 @@ -# rawdata-agent Design Spec - -Date: 2026-05-24 - -Status: proposed design - -Target project: `/Users/zhengyate/dev/openmemind/memind` - -Target location after review: `docs/superpowers/specs/2026-05-24-rawdata-agent-design.md` - -## Summary - -`rawdata-agent` is a new Memind RawData plugin that lets coding agents and other tool-using agents submit their work process as structured agent timelines. The plugin turns raw agent activity into Memind's existing AGENT memory categories: `TOOL`, `RESOLUTION`, `PLAYBOOK`, and `DIRECTIVE`. - -The design deliberately reuses Memind core instead of creating a parallel observation database. Agent timelines enter the existing pipeline: - -```text - AgentTimelineContent - -> RawData / Segment / ParsedSegment - -> ExtractedMemoryEntry - -> MemoryItem - -> Insight Tree / Graph / Retrieval -``` - -This gives Memind the coding-agent capabilities that make agentmemory and claude-mem useful, while keeping Memind's broader architecture: multi-source RawData, USER and AGENT scopes, Insight Tree, deep retrieval, graph support, and reusable Spring Boot plugin wiring. - -## Motivation - -Memind already has strong general memory architecture: RawData, MemoryItem, Insight Tree, Simple/Deep retrieval, USER and AGENT scopes, item graph support, and plugin-based raw data handling. Its current Claude Code and Codex integrations, however, intentionally focus on prompt-time retrieval and transcript ingestion. They do not ingest tool calls or agent lifecycle events in v0.1. - -That leaves a gap in coding-agent scenarios. Coding agents learn from things that are often not present in final dialogue: - -- Which files were inspected or edited. -- Which commands failed and then passed. -- Which tool usage patterns are reliable. -- Which bug was resolved and how. -- Which repeated successful workflow should become a procedural playbook. -- Which durable project or collaboration rule should be reused. - -agentmemory and claude-mem address this gap with observations and tool-call history. The Memind design should borrow that lesson without copying their storage model. In Memind, the equivalent "observation layer" should be represented as agent episode segments derived from RawData. Those segments remain traceable to raw events and feed the existing MemoryItem and Insight Tree machinery. - -## Goals - -1. Add a canonical RawData plugin for agent work process data. -2. Support Claude Code, Codex, OpenClaw, Hermes Agent, Cursor, Gemini CLI, and custom agents through a common schema. -3. Capture tool usage, command results, file interactions, permissions, errors, user goals, and task outcomes. -4. Chunk event streams into coherent agent episodes rather than isolated log lines. -5. Extract AGENT memory items: - - `TOOL`: tool and command usage experience. - - `RESOLUTION`: resolved problem and fix knowledge. - - `PLAYBOOK`: reusable procedural workflows learned from successful episodes. - - `DIRECTIVE`: durable agent behavior rules and project constraints. -6. Reuse Memind core persistence, deduplication, vector indexing, text search, item graph, Insight Tree, and retrieval. -7. Preserve privacy and safety by redacting secrets and avoiding raw large-output storage in memory items. -8. Provide a path for Memind's Claude Code and Codex integrations to capture tool events without binding core to those hosts. -9. Close the coding-agent memory capability gap with agentmemory and claude-mem while keeping Memind more general. - -## Non-Goals - -1. Do not add a new standalone memory database for agent observations. -2. Do not hard-code Claude Code or Codex semantics into `memind-core`. -3. Do not require every tool event to trigger LLM extraction. -4. Do not replace `rawdata-toolcall`; keep it as a narrow compatibility path for pure tool-call logs. -5. Do not make the first version depend on a new UI. -6. Do not make `Observation` a new first-class Memind model in the first version. -7. Do not store full sensitive tool outputs or file contents as long-term memory by default. - -## Comparison With agentmemory and claude-mem - -### claude-mem - -claude-mem is strong as a coding-memory product. It has lifecycle hooks, generated observations, a worker process, SQLite/Chroma-backed search, search/timeline/detail progressive disclosure, viewer UI, and many product skills. It is particularly polished for "what happened before in this project?" workflows. - -Memind should borrow: - -- Tool/lifecycle capture as a first-class integration concern. -- Progressive disclosure for coding history retrieval. -- Compact generated episode summaries with evidence IDs. -- Product-level Claude/Codex hook reliability and retry behavior. - -Memind should not copy: - -- A separate observation-first storage model when RawData/Segment/MemoryItem already cover most needs. -- A Claude-centric design. - -### agentmemory - -agentmemory is strong as a coding-agent runtime memory system. It has rich observations, hybrid search, graph features, lessons, procedural skill extraction, retention/decay, actions, signals, checkpoints, and many MCP/REST endpoints. - -Memind should borrow: - -- Episode-level coding observations rather than only transcript summaries. -- Procedural skill extraction from completed successful sessions. -- Lessons/playbooks as reusable agent knowledge. -- Core-compatible entity hints for files, commands, errors, tools, and modules. -- Delayed extraction after episode completion instead of per-event extraction. - -Memind should not copy: - -- A coding-only architecture. -- Runtime orchestration features such as leases/actions/checkpoints as part of `rawdata-agent` v1. -- A parallel state engine outside Memind core. - -### Memind Differentiation - -With `rawdata-agent`, Memind can become a general agent experience memory layer: - -```text -conversation + document + image + audio + toolcall + agent timeline - -> MemoryItem - -> Insight Tree - -> Retrieval -``` - -The differentiator is not just saving coding observations. It is turning agent work process into structured AGENT memory that participates in Memind's long-term knowledge evolution. - -## Architecture - -### Module Layout - -Add: - -```text -memind-plugins/ - memind-plugin-rawdatas/ - memind-plugin-rawdata-agent/ - src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/ - AgentRawContentTypeRegistrar.java - content/ - AgentTimelineContent.java - AgentSkillDocContent.java # optional v1.1, not required for v1 - model/ - AgentEvent.java - AgentEventKind.java - AgentTimeline.java - AgentEpisode.java - AgentCommand.java - AgentFileReference.java - AgentToolCall.java - AgentOutcome.java - config/ - AgentRawDataOptions.java - AgentChunkingOptions.java - AgentExtractionOptions.java - AgentPrivacyOptions.java - chunk/ - AgentTimelineChunker.java - AgentEpisodeAssembler.java - AgentSegmentFormatter.java - processor/ - AgentTimelineContentProcessor.java - caption/ - AgentCaptionGenerator.java - item/ - AgentItemExtractionStrategy.java - AgentItemPrompts.java - privacy/ - AgentEventRedactor.java - SecretPatternRedactor.java - plugin/ - AgentRawDataPlugin.java - - memind-plugin-spring-boot-starters/ - memind-plugin-rawdata-agent-starter/ - src/main/java/com/openmemind/ai/memory/plugin/rawdata/agent/autoconfigure/ - AgentRawDataAutoConfiguration.java - AgentRawDataProperties.java -``` - -Register the modules and starter in the same places as existing rawdata plugins: - -- `memind-plugins/memind-plugin-rawdatas/pom.xml` -- `memind-plugins/memind-plugin-spring-boot-starters/pom.xml` -- the parent/root module list if the repository root POM enumerates plugin modules -- `memind-server/pom.xml` -- `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` in the starter - -### Runtime Loading - -Memind server already collects `RawDataPlugin` beans through `ObjectProvider` and passes them into `Memory.builder().rawDataPlugin(...)`. `rawdata-agent` should follow the same path. - -The plugin should not require special server code for its core extraction behavior. Optional REST convenience endpoints can be added later, but v1 can use the existing extraction endpoint with `rawContent.type = "agent_timeline"`. - -### Integration Boundary - -Agent integrations are adapters. They capture host-specific events and submit normalized payloads to Memind. - -```text -Claude Code hook payload -Codex hook payload -OpenClaw event bus -Hermes trace log -Cursor transcript - -> integration adapter - -> AgentTimelineContent JSON - -> Memind server - -> rawdata-agent plugin -``` - -`rawdata-agent` owns schema validation, privacy cleanup, chunking, episode assembly, and item extraction. It does not own hook installation or host-specific event parsing. - -## Raw Content Types - -### `agent_timeline` v1 - -Primary input type. It represents a time-ordered sequence of events from one agent session, turn, task, or partial episode. - -Example: - -```json -{ - "type": "agent_timeline", - "sourceClient": "claude-code", - "sourceVersion": "1.0", - "userId": "local__alice", - "agentId": "claude-code__project_hash", - "project": { - "name": "payments-api", - "rootHash": "sha256:...", - "git": { - "branch": "fix/payment-rounding", - "commit": "abc123" - } - }, - "sessionId": "session-123", - "agentTurnId": "session-123-agent-turn-1-6", - "timelineId": "timeline-123", - "events": [ - { - "eventId": "e1", - "seq": 1, - "kind": "user_prompt", - "text": "修复 payment test", - "occurredAt": "2026-05-24T10:00:00Z" - }, - { - "eventId": "e2", - "seq": 2, - "kind": "tool_call", - "toolName": "Read", - "input": "src/payment/calc.ts", - "status": "success", - "occurredAt": "2026-05-24T10:00:12Z" - }, - { - "eventId": "e3", - "seq": 3, - "kind": "command", - "toolName": "Bash", - "command": "npm test payment", - "status": "failed", - "output": "rounding mismatch", - "durationMs": 3200, - "occurredAt": "2026-05-24T10:01:00Z" - }, - { - "eventId": "e4", - "seq": 4, - "kind": "file_edit", - "path": "src/payment/calc.ts", - "operation": "modify", - "occurredAt": "2026-05-24T10:03:00Z" - }, - { - "eventId": "e5", - "seq": 5, - "kind": "command", - "toolName": "Bash", - "command": "npm test payment", - "status": "success", - "durationMs": 4100, - "occurredAt": "2026-05-24T10:05:00Z" - }, - { - "eventId": "e6", - "seq": 6, - "kind": "stop", - "occurredAt": "2026-05-24T10:06:00Z" - } - ] -} -``` - -### Content Type Contract - -Memind currently separates external JSON discriminator names from Java internal content type constants. `rawdata-agent` must follow that convention explicitly: - -```text -External JSON rawContent.type: "agent_timeline" -RawContentTypeRegistrar subtype: "agent_timeline" -> AgentTimelineContent.class -AgentTimelineContent.TYPE: "AGENT_TIMELINE" -AgentTimelineContent.contentType(): "AGENT_TIMELINE" -RawData.contentType stored value: "AGENT_TIMELINE" -``` - -Rationale: - -- JSON subtype names use lower snake case, consistent with existing plugin subtype `tool_call`. -- Java content type constants use upper snake case, consistent with existing `ConversationContent.TYPE = "CONVERSATION"` and `ToolCallContent.TYPE = "TOOL_CALL"`. -- Client libraries can submit the feature immediately through map-based raw content without waiting for typed client models. - -### `agent_skilldoc` v1.1 - -Optional future input type for importing existing `SKILL.md`, procedure docs, and team playbooks. This is not required for historical-session procedural skill extraction. Historical skill extraction should come from `agent_timeline` episodes. - -## Event Schema - -Supported event kinds in v1: - -- `user_prompt` -- `assistant_message` -- `tool_call` -- `tool_result` -- `command` -- `file_read` -- `file_edit` -- `test_result` -- `permission_request` -- `error` -- `stop` -- `session_end` -- `task_completed` - -Required fields for all events: - -- `eventId`: stable event ID from adapter, or deterministic hash. -- `seq`: monotonic sequence number within timeline. If the host event stream does not provide one, the adapter must synthesize a stable sequence number from the normalized event order before submission. -- `kind`: event kind. -- `occurredAt`: event time, or adapter receipt time. - -Common optional fields: - -- `toolName` -- `input` -- `output` -- `status`: `success`, `failed`, `error`, `cancelled`, `unknown` -- `durationMs` -- `path` -- `operation` -- `command` -- `exitCode` -- `metadata` - -The plugin must tolerate missing optional fields and should avoid rejecting useful partial timelines unless core required fields are missing. - -## Processor Contract - -`AgentTimelineContentProcessor` must make the content behavior explicit: - -```java -contentClass() -> AgentTimelineContent.class -contentType() -> AgentTimelineContent.TYPE -allowedCategories() -> MemoryCategory.agentCategories() -usesSourceIdentity() -> true -supportsInsight() -> true -``` - -`usesSourceIdentity()` is required because two different agents, repositories, or sessions can produce identical event text. The RawData content ID must include a source identity through `resourceId`, `sourceUri`, `storageUri`, request metadata, or the content's own stable timeline fingerprint. Integration adapters should include at least: - -```json -{ - "sourceUri": "agent://claude-code/payments-api/session-123/timeline-123", - "sourceClient": "claude-code", - "sessionId": "session-123", - "timelineId": "timeline-123", - "projectRootHash": "sha256:..." -} -``` - -Current Memind open extraction requests only expose `sourceClient` as a top-level convenience field. They do not automatically promote arbitrary `rawContent` JSON fields such as `sessionId` or `timelineId` into extraction request metadata before RawData content ID calculation. Therefore `rawdata-agent` must make source identity deterministic through one of these supported paths: - -- preferred v1 path: `AgentTimelineContent.getContentId()` hashes a canonical identity payload that includes `sourceClient`, `sessionId`, `timelineId`, ordered event IDs, and a normalized event-content hash, so exact duplicate complete timeline windows produce the same RawData content ID even through the existing open extraction endpoint; -- optional API/client enhancement: extend the open extraction request and client wrappers to pass `sourceUri`, `sessionId`, `timelineId`, and `projectRootHash` as extraction metadata before `RawDataLayer.resolveRawDataContentId(...)` runs. - -If the optional metadata path is added, it must be additive and backward-compatible. The plugin must not depend on raw JSON payload fields being visible as request metadata unless that wiring is explicitly implemented. - -`supportsInsight()` should stay `true`. Disabling insight for this content type would make AGENT playbooks, resolutions, and directives persist as items only, which would weaken the main reason to add `rawdata-agent`. - -`allowedCategories()` protects the USER memory tree. Even if extraction uses `ExtractionConfig.defaults()` with USER scope, AGENT categories should be emitted and `MemoryItemLayer` should persist them under their category-defined AGENT scope. Callers should still prefer `ExtractionConfig.agentOnly()` or the equivalent server/client option when available to make intent explicit. - -## Chunking and Episode Assembly - -### Principle - -Chunking must preserve causality. A coding-agent memory chunk should describe a coherent task episode, not an arbitrary token window. - -Bad chunking: - -```text -Segment 1: Bash npm test failed. -Segment 2: Edit calc.ts. -Segment 3: Bash npm test passed. -``` - -Good chunking: - -```text -Segment: User asked to fix payment tests. npm test payment failed with rounding mismatch. The agent edited src/payment/calc.ts. npm test payment then passed. -``` - -### Episode Boundaries - -Primary boundaries: - -- `user_prompt` starts a candidate episode. -- `stop`, `session_end`, or `task_completed` ends a candidate episode. -- A new `user_prompt` closes the previous open episode if no stop event was seen. - -Secondary boundaries: - -- Time gap larger than `maxEventGap`, default 30 minutes. -- Token estimate larger than `targetEpisodeTokens`, default 2000. -- Event count larger than `maxEventsPerEpisode`, default 80. -- Explicit adapter-provided `taskId` or `subtaskId` changes. - -### Phase Splitting - -If an episode exceeds budget, split by phase while preserving shared context: - -- `investigation`: reads, searches, failed commands, diagnostics. -- `implementation`: edits, patches, refactors. -- `validation`: tests, builds, checks, final verification. -- `handoff`: summary, stop, session end. - -Each phase segment must include: - -- `episodeId` -- `goal` -- `phase` -- `outcome` -- important files -- important commands -- evidence event IDs - -### Segment Text Format - -`AgentSegmentFormatter` should produce compact, deterministic text: - -```text -Goal: Fix payment tests. -Outcome: success -Project: payments-api -Files: src/payment/calc.ts -Commands: -- npm test payment -> failed: rounding mismatch -- npm test payment -> success -Actions: -- Read src/payment/calc.ts -- Modified src/payment/calc.ts -Evidence: -- e3: failed test output mentioned rounding mismatch -- e5: npm test payment passed -``` - -### Segment Metadata - -Each segment metadata should include: - -```json -{ - "segmentType": "agent_episode", - "sourceClient": "claude-code", - "sessionId": "session-123", - "timelineId": "timeline-123", - "episodeId": "episode-123", - "phase": "full", - "goal": "Fix payment tests", - "outcome": "success", - "projectName": "payments-api", - "projectRootHash": "sha256:...", - "gitBranch": "fix/payment-rounding", - "files": ["src/payment/calc.ts"], - "commands": ["npm test payment"], - "toolNames": ["Read", "Bash", "Edit"], - "failureSignals": ["rounding mismatch"], - "eventIds": ["e1", "e2", "e3", "e4", "e5", "e6"], - "windowStart": "2026-05-24T10:00:00Z", - "windowEnd": "2026-05-24T10:06:00Z" -} -``` - -Do not persist absolute local project roots by default. They can expose usernames, customer names, or private filesystem layout through durable RawData metadata. If an integration needs raw absolute paths for a local-only deployment, make it an explicit opt-in field such as `projectRootRaw`, disabled by default. - -This metadata is the Memind equivalent of an observation evidence layer. It avoids adding a new `Observation` table while preserving traceability. - -## Observation Layer Decision - -Do not add a first-class `Observation` model in v1. - -Memind already has: - -- RawData for original input and traceability. -- Segment / ParsedSegment for chunked evidence. -- MemoryItem for durable atomic memory. -- Insight for higher-level synthesis. - -`rawdata-agent` should use `segmentType = "agent_episode"` metadata to represent observation-like units. If future UI, citation, replay, or manual-edit workflows require a durable first-class episode projection, add `AgentEpisode` later as a projection derived from RawData, not as a replacement for MemoryItem. - -Future optional projection: - -```text -AgentEpisode: - id - memoryId - rawDataId - sessionId - sourceClient - goal - outcome - summary - files - commands - eventIds - createdAt -``` - -## Item Extraction - -### Strategy - -`AgentItemExtractionStrategy` should be content-type-specific. The default conversation item extractor is not enough because agent timelines need coding-domain semantics. - -The strategy should combine deterministic extraction and LLM extraction: - -Deterministic extraction: - -- Tool and command usage facts. -- Files touched. -- Test commands and status. -- Outcome. -- Event IDs and evidence metadata. -- Core-compatible entity hints for files, commands, tools, errors, and modules. - -LLM extraction: - -- Resolved problem and fix summaries. -- Reusable playbooks. -- Durable directives. -- Normalized failure modes. -- Concise content suitable for long-term memory. - -### Output Contract - -The strategy outputs `ExtractedMemoryEntry` objects. - -Required fields: - -- `content` -- `confidence` -- `observedAt` -- `rawDataId` -- `insightTypes` -- `metadata` -- `type` -- `category` - -Structured LLM extraction should use a constrained response schema before conversion to `ExtractedMemoryEntry`: - -```json -{ - "items": [ - { - "category": "resolution", - "content": "Payment tests failed because rounding behavior in src/payment/calc.ts did not match the expected policy; editing calc.ts and rerunning npm test payment resolved the failure.", - "confidence": 0.86, - "insightTypes": ["resolutions"], - "entities": [ - {"entityType": "object", "name": "src/payment/calc.ts", "salience": 0.9}, - {"entityType": "object", "name": "npm test payment", "salience": 0.8}, - {"entityType": "concept", "name": "rounding mismatch", "salience": 0.8} - ], - "metadata": { - "episodeId": "episode-123", - "outcome": "success", - "files": ["src/payment/calc.ts"], - "commands": ["npm test payment"], - "toolNames": ["Bash", "Read", "Edit"], - "failureSignals": ["rounding mismatch"], - "evidenceEventIds": ["e3", "e4", "e5"], - "codingEntities": { - "files": ["src/payment/calc.ts"], - "commands": ["npm test payment"], - "errors": ["rounding mismatch"], - "tools": ["Bash", "Read", "Edit"] - } - } - } - ] -} -``` - -This schema intentionally uses the existing `MemoryItemExtractionResponse.ExtractedItem` shape so the agent extractor can share the same structured response vocabulary as the default item extractor. Coding-specific typed details stay in metadata. Do not introduce a plugin-owned graph payload with custom relation names in v1. - -Implementation note: `AgentItemExtractionStrategy` is content-type-specific and ultimately returns `ExtractedMemoryEntry` objects. Therefore it must not assume that core will automatically convert its intermediate LLM response into graph hints unless it routes through shared conversion code. The implementation must either: - -- extract a small core support converter from the existing default item extractor path and reuse it, or -- explicitly convert `ExtractedItem.entities` and `ExtractedItem.causalRelations` into `ExtractedGraphHints` when building each `ExtractedMemoryEntry`. - -Do not call private methods or duplicate hidden behavior through reflection. The graph hint conversion should be a normal, testable code path. - -If a single episode produces multiple memory items and there is a true item-to-item causal relationship, the extractor may emit current-core-compatible causal relations: - -```json -{ - "items": [ - { - "category": "resolution", - "content": "Payment tests failed because rounding behavior did not match policy.", - "confidence": 0.82, - "insightTypes": ["resolutions"], - "entities": [{"entityType": "concept", "name": "rounding mismatch", "salience": 0.8}], - "metadata": {"evidenceEventIds": ["e3"]} - }, - { - "category": "resolution", - "content": "Editing src/payment/calc.ts and rerunning npm test payment resolved the failure.", - "confidence": 0.86, - "insightTypes": ["resolutions"], - "entities": [{"entityType": "object", "name": "src/payment/calc.ts", "salience": 0.9}], - "causalRelations": [ - {"causeIndex": 0, "effectIndex": 1, "relationType": "enabled_by", "strength": 0.7} - ], - "metadata": {"evidenceEventIds": ["e4", "e5"]} - } - ] -} -``` - -`causeIndex` and `effectIndex` reference memory item indexes in the same response, not entity indexes. Relation types must be limited to the current Memind causal vocabulary: `caused_by`, `enabled_by`, and `motivated_by`. - -Custom coding relation names should not be emitted as graph hints in v1. Semantics such as "fixed error", "touched file", or "validated module" should be represented through item content and metadata fields such as `failureSignals`, `files`, `commands`, `toolNames`, and `codingEntities`. - -Rules: - -- Every LLM-produced item must reference at least one `metadata.evidenceEventIds` value present in the segment metadata. -- The extractor must drop items whose category is not one of `tool`, `resolution`, `playbook`, or `directive`. -- The extractor must drop playbooks without trigger, steps, and expected outcome. -- The extractor must drop resolutions without both problem and fix/conclusion. -- Deterministic metadata such as `episodeId`, `sessionId`, `timelineId`, `sourceClient`, files, commands, and event IDs should be merged into every emitted item before persistence. -- Core-compatible graph hints are optional. When present, entity types must map to current Memind graph entity types, and causal relations must use current Memind causal relation codes. A custom agent extraction strategy must populate `ExtractedMemoryEntry.graphHints()` explicitly or through a shared core converter. - -`MemoryItemLayer` then handles: - -- category validation against `allowedCategories` -- insight type normalization -- self-verification if enabled -- deduplication -- vectorization -- persistence -- graph materialization - -### Allowed Categories - -`AgentTimelineContentProcessor.allowedCategories()` must return: - -```java -MemoryCategory.agentCategories() -``` - -This prevents agent execution traces from being misclassified as USER profile/event memory. - -### Category to Insight Type Mapping - -`rawdata-agent` should assign insight types deterministically from category unless a specialized extractor has a stronger reason: - -```text -category=directive -> insightTypes=["directives"] -category=playbook -> insightTypes=["playbooks"] -category=resolution -> insightTypes=["resolutions"] -category=tool -> insightTypes=["tools"] -``` - -### Category Mapping - -#### TOOL - -Use when the memory describes tool, command, or execution behavior. - -Required signal: - -- `toolName` or `command` - -Examples: - -```text -Use npm test payment to validate changes in src/payment/calc.ts. -``` - -```text -Bash command npm test payment failed with rounding mismatch before the fix and passed afterward. -``` - -Metadata: - -```json -{ - "toolName": "Bash", - "command": "npm test payment", - "files": ["src/payment/calc.ts"], - "successCount": 1, - "failCount": 1 -} -``` - -#### RESOLUTION - -Use when the memory describes a resolved problem pattern with usable fix or conclusion. - -Required signals: - -- problem/failure mode -- fix/conclusion -- outcome or evidence - -Example: - -```text -Payment tests failed because rounding behavior in src/payment/calc.ts did not match the expected policy; editing calc.ts and rerunning npm test payment resolved the failure. -``` - -Metadata: - -```json -{ - "problem": "rounding mismatch", - "fix": "updated payment calculation logic", - "outcome": "success", - "files": ["src/payment/calc.ts"], - "commands": ["npm test payment"], - "evidenceEventIds": ["e3", "e4", "e5"] -} -``` - -#### PLAYBOOK - -Use when the memory describes a reusable procedural workflow. - -Required signals: - -- trigger condition -- at least two concrete steps -- expected outcome -- preferably success outcome - -Example: - -```text -When payment tests fail with rounding mismatch, inspect the payment calculation policy, edit src/payment/calc.ts if needed, then run npm test payment until it passes. -``` - -Metadata: - -```json -{ - "trigger": "payment tests fail with rounding mismatch", - "steps": [ - "Inspect payment calculation policy", - "Check src/payment/calc.ts", - "Run npm test payment after edits" - ], - "expectedOutcome": "payment tests pass", - "sourceEpisodeIds": ["episode-123"], - "strength": 0.6 -} -``` - -#### DIRECTIVE - -Use when the memory describes a durable rule, collaboration boundary, or project constraint. - -Example: - -```text -Do not change the public payment calculation API without updating payment integration tests. -``` - -Metadata: - -```json -{ - "scope": "project", - "files": ["src/payment/calc.ts"], - "sourceEpisodeIds": ["episode-123"] -} -``` - -## Insight Type Mapping - -Memind already has AGENT insight types: - -- `directives` -- `playbooks` -- `resolutions` - -Mapping: - -```text -category=directive -> insightTypes=["directives"] -category=playbook -> insightTypes=["playbooks"] -category=resolution -> insightTypes=["resolutions"] -``` - -Current default insight types do not include `tools`, and current `rawdata-toolcall` disables insight building. For `rawdata-agent`, tool usage is not secondary evidence; it is one of the main coding-agent memory surfaces. The v1 implementation must add a default AGENT insight type: - -```text -name: tools -categories: ["tool"] -scope: AGENT -mode: BRANCH -description: Tool and command usage patterns, grouped by toolName or command family. -``` - -Map: - -```text -category=tool -> insightTypes=["tools"] -``` - -This is a small core-level enhancement, not a separate graph engine. It requires updating `DefaultInsightTypes.all()`, store initialization tests, prompt expectations where default insight sets are asserted, and any docs that list built-in insight types. - -Existing stores need special handling. Current store implementations seed `DefaultInsightTypes.all()` when the insight type table is first created; adding `tools` only to the default list is not enough for already-initialized deployments. The implementation must add a migration-safe, idempotent default insight type reconciliation path so existing SQLite, MySQL, PostgreSQL, and in-memory stores can receive the new built-in `tools` type without overwriting user-customized insight types. - -If TOOL items persist with `insightTypes=[]`, the v1 implementation should be considered incomplete because tool usage knowledge would not participate in Insight Tree synthesis. - -This makes tool usage knowledge more discoverable without relying only on item retrieval. - -## Procedural Skill Extraction - -Procedural skill extraction should not be a separate `rawdata-skills` path for historical sessions. Historical procedural skills are outputs of successful agent episodes. - -In Memind terminology, these should be `AGENT / PLAYBOOK` memory items. - -Extraction policy: - -- Only attempt playbook extraction from successful or partially successful episodes by default. -- Require at least `minEventsForPlaybook`, default 5. -- Require a clear trigger, steps, and expected outcome. -- Do not extract playbooks from exploratory sessions without a reusable procedure. -- Avoid duplicate playbooks through deterministic content, existing deduplication, and source metadata. -- Reinforce existing similar playbooks only when an explicit merge/update path exists. - -Example: - -```text -Trigger: JWT expiry tests fail around token TTL or fake timer behavior. -Steps: -1. Inspect token TTL configuration in auth/session.ts. -2. Check fake timer setup in auth tests. -3. Run npm test auth after changes. -Expected outcome: Auth tests pass and JWT expiry behavior matches project policy. -``` - -Store as: - -```text -category = playbook -insightTypes = ["playbooks"] -metadata.sourceEpisodeIds = [...] -metadata.steps = [...] -metadata.trigger = ... -metadata.expectedOutcome = ... -``` - -The first implementation should not overclaim automatic reinforcement. Existing item deduplication can skip exact or semantically duplicate items depending on the configured deduplicator, but it does not by itself update `strength`, `sourceEpisodeIds`, or other metadata on an existing item. If v1 needs reinforcement semantics, add a dedicated playbook consolidation step that updates the existing item or Insight Buffer explicitly; otherwise treat reinforcement as Phase 5 tuning. - -## Extraction Timing - -Do not extract long-term memory on every tool event. - -Recommended lifecycle: - -```text -PostToolUse: - collect event into integration-local timeline buffer or retry spool - no LLM item extraction required per event - -Stop: - submit or flush the completed timeline window to Memind - assemble episode - extract TOOL - extract RESOLUTION only when resolved-problem evidence is present - optionally extract PLAYBOOK candidate if success and complexity thresholds pass - -PreCompact: - flush open episode before host context compaction - -SessionEnd: - flush remaining events - -Background / scheduled consolidation: - build Insight Tree - optionally merge duplicate playbooks if an explicit consolidation job exists - optionally strengthen repeated procedures if an explicit update path exists -``` - -Default config: - -```properties -memind.rawdata.agent.enabled=true -memind.rawdata.agent.extract-on-stop=true -memind.rawdata.agent.extract-on-session-end=true -memind.rawdata.agent.extract-on-every-tool=false -memind.rawdata.agent.min-events-for-extraction=3 -memind.rawdata.agent.min-events-for-playbook=5 -memind.rawdata.agent.require-success-for-playbook=true -memind.rawdata.agent.max-event-gap=PT30M -memind.rawdata.agent.consolidation-interval=PT2H -``` - -The plugin itself can process whatever payload is submitted. The integration decides when to submit. For Claude Code and Codex, the preferred first implementation is submit on `Stop`, `PreCompact`, and `SessionEnd`, with optional lightweight per-tool event spooling. - -v1 should not add server-side append or event-buffer semantics. The payload submitted to Memind should be a complete timeline window. Local adapters own partial event buffering, retry, and replay. This keeps the server aligned with the existing RawData extraction model and avoids introducing a second stateful ingestion protocol before there is a clear need. - -## Privacy and Redaction - -`rawdata-agent` must redact sensitive data before segment formatting, RawData persistence, vectorization, and item extraction. - -This is a hard requirement because the current RawData pipeline persists the `Segment` produced by `chunk(...)`. If the plugin only redacts inside the item extraction prompt, the original sensitive tool output can still be stored in RawData and vectors. Therefore redaction must happen before `AgentTimelineChunker` returns durable segments, and integrations should also redact obvious secrets before writing local retry spool files. - -Default redaction: - -- API keys and bearer tokens. -- Common secret env vars. -- Database URLs with credentials. -- SSH/private key material. -- Cloud credentials. -- Long command outputs beyond configured limit. -- File contents unless explicitly allowed. - -Config: - -```properties -memind.rawdata.agent.privacy.redact-secrets=true -memind.rawdata.agent.privacy.max-output-chars=4000 -memind.rawdata.agent.privacy.max-input-chars=2000 -memind.rawdata.agent.privacy.capture-file-content=false -memind.rawdata.agent.privacy.allow-path-patterns= -memind.rawdata.agent.privacy.deny-path-patterns=.env,*.pem,*.key -``` - -If redaction changes content, metadata should include: - -```json -{ - "redacted": true, - "redactionKinds": ["api_key", "database_url"] -} -``` - -Raw event payloads submitted by integrations should follow the same rule. Default v1 behavior should not persist full command output or file contents. Store bounded summaries, status, exit code, hashes, file paths, and short redacted snippets instead. - -## Deduplication and Idempotency - -Integrations may retry submissions. Hooks may fire repeatedly. The plugin must be idempotency-friendly. - -Identity fields: - -- `timelineId` -- `sessionId` -- `event.eventId` -- `event.seq` -- `sourceClient` -- `contentHash` - -Episode ID should be deterministic: - -```text -hash(sourceClient + sessionId + firstEventId + lastEventId + eventIds) -``` - -Item content should be deterministic enough for existing MemoryItem deduplication to work. Metadata should preserve source IDs so future consolidation can identify repeated procedures and related source episodes. - -Exact duplicate submissions of the same complete timeline window should be treated as idempotent: same `agentTurnId`, same `timelineId`, same ordered event IDs, same content hash, and same deterministic episode IDs should not produce duplicate durable items. Overlapping or partial windows are harder: the adapter must keep a watermark or window identity so retries and later flushes can be distinguished. The plugin should normalize and skip duplicate events within one submitted payload by `sourceClient + sessionId + agentTurnId + timelineId + event.eventId`; it should not claim universal deduplication or server-side merging for arbitrarily overlapping windows without adapter identity. - -Item-level idempotency must be explicit. Current Memind RawData idempotency can replay existing segments for an already-seen content ID, and item extraction may still run after RawData replay. Existing item deduplication is content-hash based and cannot guarantee duplicate suppression if an LLM produces slightly different wording on retry. - -For `agent_timeline`, implement at least one of these v1 safeguards: - -- skip item extraction when the RawData result is an exact duplicate replay for `AGENT_TIMELINE`, if the core extraction flow exposes that state to the processor or item extraction layer; or -- make `AgentItemExtractionStrategy` produce deterministic canonical content and/or deterministic item-level dedup metadata based on `episodeId + phase + category + evidenceEventIds + normalized payload`, so exact duplicate complete timeline windows yield the same item content hashes; or -- add a plugin-compatible item idempotency key that is checked before persistence. - -The acceptance target is exact duplicate complete-window idempotency. Arbitrary overlapping windows remain an adapter responsibility unless a future server-side append/window protocol is introduced. - -## Graph Hints - -`rawdata-agent` must reuse Memind's current item graph path. It should not introduce `AgentGraphHintsBuilder`, a plugin-owned graph schema, or coding-specific relation types in v1. - -The supported path is: - -```text -AgentItemExtractionStrategy - -> optional MemoryItemExtractionResponse.ExtractedItem.entities as intermediate schema - -> shared converter or explicit conversion - -> ExtractedMemoryEntry.graphHints().entities - -> existing item graph materialization -``` - -and, only when there is a true item-to-item causal link: - -```text -AgentItemExtractionStrategy - -> optional MemoryItemExtractionResponse.ExtractedItem.causalRelations as intermediate schema - -> shared converter or explicit conversion - -> ExtractedMemoryEntry.graphHints().causalRelations - -> CausalHintNormalizer -``` - -The `MemoryItemExtractionResponse.ExtractedItem` shape is an intermediate LLM response contract, not a persistence contract. The persisted handoff to the existing graph layer is `ExtractedMemoryEntry.graphHints()`. - -Coding-domain objects should be represented with the current `GraphEntityType` vocabulary: - -```text -file path -> OBJECT -command -> OBJECT -tool name -> OBJECT -module/package -> CONCEPT or OBJECT, depending on context -failure signal -> CONCEPT -framework/library -> ORGANIZATION, OBJECT, or CONCEPT, depending on how the source names it -``` - -The original typed coding semantics stay in item metadata: - -```json -{ - "files": ["src/payment/calc.ts"], - "commands": ["npm test payment"], - "toolNames": ["Bash", "Read", "Edit"], - "failureSignals": ["rounding mismatch"], - "codingEntities": { - "files": ["src/payment/calc.ts"], - "commands": ["npm test payment"], - "errors": ["rounding mismatch"], - "tools": ["Bash", "Read", "Edit"], - "modules": ["payment"] - } -} -``` - -Do not emit these as graph relation types: - -```text -FIXES -TOUCHES_FILE -VALIDATES -APPLIES_TO -EXECUTES -``` - -Those are useful coding semantics, but the current Memind graph layer only supports generic entities plus bounded causal item links. Express the coding semantics in memory content and metadata first. Use `caused_by`, `enabled_by`, or `motivated_by` only for causal item-to-item links that meet existing `CausalHintNormalizer` constraints. - -## Retrieval and Prompt Injection - -`rawdata-agent` becomes valuable only if integrations retrieve and format AGENT memory effectively. - -Claude Code and Codex retrieval hooks should format AGENT memory separately from USER/project memories: - -```text - -Relevant memories from Memind. Use only when directly helpful: - -## Agent Playbooks -- When JWT expiry tests fail, inspect fake timers, check token TTL config, then run npm test auth. - -## Resolved Problems -- Payment rounding mismatch was fixed in src/payment/calc.ts and validated with npm test payment. - -## Tool Notes -- Use npm test payment to validate payment calculation changes. - -## Directives -- Do not change public payment API without updating integration tests. - -``` - -Retrieval should support the filters Memind already exposes well: - -- by AGENT scope -- by category -- by time range when available - -The item content and metadata should still include project name, project root hash, source client, session, files, modules, and commands so lexical/vector retrieval, graph expansion, admin tooling, and integration-side grouping can use those signals. Do not require new core metadata-filter semantics for v1 unless they are implemented as a separate retrieval enhancement. - -If core retrieval does not yet expose enough category-aware formatting or metadata filtering, integration formatting should use returned item metadata to group results and should rely on query text plus AGENT/category filters for the first release. - -## API and Client Changes - -### Preferred v1 Path - -Use existing extraction endpoint with a new raw content type: - -```text -POST /open/v1/memory/async/extract -rawContent.type = "agent_timeline" -``` - -This keeps the feature aligned with Memind's RawData abstraction. - -### Optional Convenience Endpoint - -Add later if needed: - -```text -POST /open/v1/agent/timeline -``` - -This endpoint should translate request body into `AgentTimelineContent` and call the same memory extraction service. It must not bypass core extraction. - -### Client Libraries - -Python client should expose: - -```python -await client.memory.extract_agent_timeline( - user_id=..., - agent_id=..., - timeline=..., - source_client="claude-code", -) -``` - -This should be a convenience wrapper around `extract(raw_content=MapRawContent(type="agent_timeline", properties={...}))`. - -Typed models can be added after the server plugin lands: - -- Python: `AgentTimelineContent`, `AgentEvent`, and `RawContentValue = ConversationContent | MapRawContent | AgentTimelineContent`. -- Java: `AgentTimelineContent` client model or a documented `MapRawContent.of("agent_timeline", properties)` example. -- TypeScript integrations: plain JSON is acceptable in v1 as long as tests validate the exact serialized payload. - -The compatibility path matters because existing Python and Java clients already support arbitrary map-backed raw content. `rawdata-agent` should not require a client release before early adopters can test the server plugin. - -This compatibility still depends on server-side registration. Map-backed clients can serialize the payload immediately, but the Memind server must load `AgentRawContentTypeRegistrar` through the rawdata plugin or starter so Jackson can resolve `rawContent.type = "agent_timeline"` into `AgentTimelineContent`. Without the registrar, clients should expect a normal unsupported raw content type error rather than a silent fallback. - -## Claude Code and Codex Integration Changes - -### Current Behavior - -Current Memind integrations retrieve before user prompts and ingest transcript user/assistant messages after turns. They intentionally do not ingest tool calls in v0.1. - -### Required Additions - -Claude Code integration: - -- Add `PreToolUse` and `PostToolUse` hook scripts. -- Optionally add `PermissionRequest`. -- Keep `Stop`, `PreCompact`, and `SessionEnd` as flush points. -- Maintain retry spool under `~/.memind/claude-code/retry/`. -- Avoid blocking hot tool paths; per-tool scripts should be best-effort and short-timeout. - -Codex integration: - -- Add supported hook scripts for `PreToolUse`, `PostToolUse`, and optionally `PermissionRequest` where available. -- Keep `Stop` as the primary flush point. -- Maintain retry spool under `~/.memind/codex/retry/`. - -Adapter responsibilities: - -- Normalize host payload into agent timeline events. -- Generate stable event IDs and sequence numbers. -- Redact obvious local secrets before writing retry spool when possible. -- Submit complete timeline windows on flush. -- Fail open so agent workflows are not blocked by Memind downtime. - -## Relationship With `rawdata-toolcall` - -Keep `rawdata-toolcall`. - -Roles: - -```text -rawdata-toolcall - narrow path for systems that only have tool-call logs - -rawdata-agent - canonical path for complete agent work process timelines -``` - -`rawdata-toolcall` remains a separate raw data type for pure tool-call logs. `rawdata-agent` may reuse deterministic tool telemetry ideas from `rawdata-toolcall`, but must not run the toolcall LLM extraction path by default and must not double-ingest Claude Code or Codex tool activity. - -Long-term, `rawdata-toolcall` can internally adapt pure tool-call records into `agent_timeline` episodes to reuse the same item extraction logic. This avoids duplicate tool extraction prompts and inconsistent TOOL memories. - -## Configuration - -Recommended properties: - -```properties -memind.rawdata.agent.enabled=true - -memind.rawdata.agent.chunking.target-episode-tokens=2000 -memind.rawdata.agent.chunking.hard-max-tokens=4000 -memind.rawdata.agent.chunking.max-events-per-episode=80 -memind.rawdata.agent.chunking.max-event-gap=PT30M - -memind.rawdata.agent.extraction.extract-tool=true -memind.rawdata.agent.extraction.extract-resolution=true -memind.rawdata.agent.extraction.extract-playbook=true -memind.rawdata.agent.extraction.extract-directive=true -memind.rawdata.agent.extraction.extract-on-every-tool=false -memind.rawdata.agent.extraction.min-events-for-extraction=3 -memind.rawdata.agent.extraction.min-events-for-playbook=5 -memind.rawdata.agent.extraction.require-success-for-playbook=true - -memind.rawdata.agent.privacy.redact-secrets=true -memind.rawdata.agent.privacy.max-input-chars=2000 -memind.rawdata.agent.privacy.max-output-chars=4000 -memind.rawdata.agent.privacy.capture-file-content=false -memind.rawdata.agent.privacy.deny-path-patterns=.env,*.pem,*.key -``` - -## Testing Strategy - -### Unit Tests - -Add tests for: - -- `AgentTimelineContent` JSON serialization/deserialization. -- `AgentRawContentTypeRegistrar`. -- `RawContentJackson.registerPluginSubtypes(...)` maps JSON `agent_timeline` to `AgentTimelineContent`. -- `AgentTimelineChunker`. -- Episode boundary detection. -- Phase splitting for large episodes. -- Secret redaction. -- Deterministic episode IDs. -- Category mapping. -- Insight type mapping. -- Core-compatible entity and causal graph hint construction. -- Tool-only timeline behavior. -- Unsuccessful episode behavior. -- Playbook extraction gating. -- Absolute project roots are not persisted by default; `projectRootRaw` requires explicit opt-in. - -### Integration Tests - -Add plugin integration tests: - -- `Memory.builder().rawDataPlugin(new AgentRawDataPlugin(...))` accepts `agent_timeline`. -- Extracting a successful coding timeline produces TOOL items. -- Extracting a coding timeline with explicit failure, fix/conclusion, and later validation evidence produces RESOLUTION items. -- Successful complex episode can produce PLAYBOOK. -- Failed/unresolved episode does not produce PLAYBOOK by default. -- Items are stored under AGENT scope categories only. -- Exact duplicate complete timeline window submission does not duplicate durable memory items. -- Insight building includes playbooks/resolutions/directives. -- `tools` insight type is used for TOOL items. -- JDBC JSON codec round-trips `AgentTimelineContent` when plugin subtype registration is active. -- SQLite/MySQL/Postgres store integration tests continue to read existing RawData rows after the new subtype is registered. - -### Client/Hook Tests - -Claude Code and Codex integration tests should verify: - -- Hook payload normalization. -- Retry spool persistence and replay. -- Stop flush builds one timeline payload. -- Existing transcript ingestion still works. -- Tool event ingestion can be disabled. -- Missing transcript/tool fields fail open. -- Secrets are not written to debug logs in plain text. - -### Evaluation - -Create coding-agent memory evaluation fixtures: - -- Auth/JWT failing test fixed and reused later. -- Payment rounding bug resolved and later queried. -- Project directive learned and enforced later. -- Command validation memory retrieved for related file. -- Repeated workflows consolidated into playbook. - -Metrics: - -- recall relevance for coding queries -- answer correctness with retrieved memory -- token cost per session -- duplicate item rate -- secret redaction false negatives -- extraction latency - -## Rollout Plan - -### Phase 1: Core Plugin - -- Add `memind-plugin-rawdata-agent`. -- Add `AgentTimelineContent`. -- Add registrar and processor. -- Implement redaction. -- Implement episode chunker. -- Implement deterministic TOOL extraction baseline. -- Implement conservative deterministic RESOLUTION candidates only when the episode contains an explicit failure signal, a later successful validation signal, and evidence tying the fix or conclusion to the outcome. -- Register starter in server. -- Add `tools` default insight type and migration-safe store initialization coverage. -- Add tests. - -### Phase 2: LLM Agent Item Extraction - -- Add `AgentItemExtractionStrategy`. -- Add structured prompt/response for TOOL, RESOLUTION, PLAYBOOK, DIRECTIVE. -- Add playbook gating. -- Add core-compatible entity hints and causal item hints. -- Add tests with mocked LLM. - -### Phase 3: Claude Code and Codex Ingestion - -- Add optional tool-event hooks. -- Add timeline buffer/spool. -- Submit timeline on Stop/PreCompact/SessionEnd. -- Keep fail-open behavior. -- Add config flags and docs. - -### Phase 4: Retrieval Formatting - -- Update Claude/Codex retrieval formatting to group AGENT memories. -- Add category-aware display. -- Add examples and troubleshooting docs. - -### Phase 5: Evaluation and Tuning - -- Tune Insight Tree grouping for playbooks/resolutions. -- Add coding-agent benchmark fixtures. - -### Phase 6: Optional SkillDoc Input - -- Add `agent_skilldoc` if importing existing `SKILL.md` and team playbooks becomes a priority. - -## Acceptance Criteria - -1. Memind server can ingest `rawContent.type = "agent_timeline"` through the normal extraction endpoint. -2. The plugin is loaded through Spring Boot starter and `RawDataPlugin`, not through special-case server code. -3. A successful coding timeline produces AGENT memory items for TOOL. -4. A coding timeline with explicit failure, fix/conclusion, and later validation evidence produces AGENT RESOLUTION memory items. -5. A successful, sufficiently complex coding timeline can produce PLAYBOOK. -6. DIRECTIVE extraction is possible but conservative. -7. No USER categories are emitted by `rawdata-agent`. -8. Exact duplicate complete timeline window submissions do not create duplicate durable memory items. -9. Secret-like values are redacted before RawData persistence, vectorization, segment text, and item extraction. -10. Existing conversation/document/image/audio/toolcall extraction remains compatible. -11. Claude Code and Codex integrations can enable tool/timeline capture without breaking current transcript ingestion. -12. Retrieved AGENT memories are formatted so coding agents can use playbooks, resolutions, tool notes, and directives directly. -13. TOOL memories can participate in AGENT insight building through the `tools` insight type. -14. The plugin works with `MapRawContent` clients before typed client models are released when the server-side `AgentRawContentTypeRegistrar` is loaded. -15. Each extracted item preserves source evidence metadata: `sourceClient`, `sessionId`, `timelineId`, `episodeId`, and `evidenceEventIds`. -16. Default metadata and RawData do not persist absolute local project roots unless `projectRootRaw` capture is explicitly enabled. - -## Open Design Decisions - -### First-Class AgentEpisode Projection - -Recommendation: do not add in v1. - -Use RawData + `agent_episode` segment metadata first. Add a first-class projection later only if Memind needs UI browsing, citation, replay, manual curation, or timeline-detail retrieval equivalent to claude-mem. - -### `rawdata-toolcall` Internal Adapter - -Recommendation: defer. - -Keep `rawdata-toolcall` working as-is for compatibility. After `rawdata-agent` is stable, adapt tool-call-only records into `agent_timeline` internally so both paths share the same TOOL extraction and insight mapping. - -### Dedicated `/open/v1/agent/timeline` Endpoint - -Recommendation: not required for v1. - -Use `memory/extract` with `rawContent.type = "agent_timeline"` first. Add endpoint later as a convenience wrapper if client ergonomics demand it. - -### PreToolUse Context - -`rawdata-agent` stores enough deterministic file/tool metadata to support a retrieval-time PreToolUse context compiler. -The compiler should use existing `tool`, `resolution`, `playbook`, `directive`, and `agent_episode` data. It must not -change the rawdata storage model, must not run `rawdata-toolcall` extraction, and must not add per-tool LLM calls. - -## Risks and Mitigations - -### Risk: Too Much Noise - -Mitigation: - -- Extract on episode end, not every event. -- Require success for playbooks. -- Use confidence thresholds. -- Use deduplication and source episode metadata. - -### Risk: Token Cost - -Mitigation: - -- Deterministic compact segment formatter. -- Truncate large tool outputs. -- Rule-based TOOL extraction. -- LLM extraction only for episode summaries/playbooks. - -### Risk: Secret Leakage - -Mitigation: - -- Redact before formatting segments. -- Avoid file-content capture by default. -- Add tests with common secret formats. -- Preserve redaction metadata. - -### Risk: Host-Specific Complexity Leaks Into Core - -Mitigation: - -- Keep Claude/Codex/OpenClaw/Hermes conversion in integrations. -- Plugin accepts normalized schema only. -- Core only sees RawContent/Segment/MemoryItem. - -### Risk: Duplicates From Hook Retries - -Mitigation: - -- Stable event IDs. -- Deterministic episode IDs. -- Existing MemoryItem deduplication. -- Retry spool only marks submitted after success. - -## Final Recommendation - -Implement `memind-plugin-rawdata-agent` as the canonical agent work-process input plugin. Keep the first version focused on `agent_timeline`, episode chunking, AGENT item extraction, privacy, idempotency, `tools` insight support, and Claude/Codex ingestion. Do not add a first-class Observation model yet; represent observation-like evidence as `agent_episode` segments and metadata. - -This design gives Memind a coding-agent memory path comparable to agentmemory and claude-mem while preserving Memind's strongest advantage: a general, extensible memory kernel that can evolve agent experience into structured long-term understanding. From 51d480fed45a3f24605ea32069fb37aab18a3ce7 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 17:59:12 +0800 Subject: [PATCH 51/54] chore: ignore Python cache files --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 945ab28e..8e2de34e 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,10 @@ target/ ### Mac OS ### .DS_Store +### Python ### +__pycache__/ +*.py[cod] + .flattened-pom.xml **/dependency-reduced-pom.xml From c4edbd4b2dfb87ae30f06cd26c83ec65c55eff74 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 18:02:30 +0800 Subject: [PATCH 52/54] chore: ignore generated client artifacts --- .gitignore | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.gitignore b/.gitignore index 8e2de34e..4f25e79f 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,30 @@ target/ __pycache__/ *.py[cod] +### JavaScript / TypeScript ### +node_modules/ +.pnpm-store/ +dist/ +dist-ssr/ +coverage/ +.turbo/ +.next/ +.vite/ +.vitest-attachments/ +**/__screenshots__/ +*.tsbuildinfo +*.tgz +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +### Go ### +*.test +*.out +coverage.out +*.coverprofile + .flattened-pom.xml **/dependency-reduced-pom.xml From ff299a0ba54c9707112377154984ae3315250169 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 18:08:04 +0800 Subject: [PATCH 53/54] test(python): sort memory test imports --- memind-clients/python/tests/test_client.py | 2 +- memind-clients/python/tests/test_models.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/memind-clients/python/tests/test_client.py b/memind-clients/python/tests/test_client.py index 4a68365d..749f9ad4 100644 --- a/memind-clients/python/tests/test_client.py +++ b/memind-clients/python/tests/test_client.py @@ -27,8 +27,8 @@ QueryMemoryItemsRequest, QueryMemoryRawDataRequest, RawDataQueryIncludeOptions, - RetrieveMemoryRequest, RetrieveIncludeOptions, + RetrieveMemoryRequest, Strategy, TimeRange, ) diff --git a/memind-clients/python/tests/test_models.py b/memind-clients/python/tests/test_models.py index d5416429..32d4d59d 100644 --- a/memind-clients/python/tests/test_models.py +++ b/memind-clients/python/tests/test_models.py @@ -26,8 +26,6 @@ CommitMemoryRequest, ExtractMemoryRequest, ExtractMemoryResponse, - RetrieveMemoryRequest, - RetrieveMemoryResponse, MetadataCondition, MetadataFilter, QueryMemoryItemsRequest, @@ -36,6 +34,8 @@ QueryMemoryRawDataResponse, RawDataQueryIncludeOptions, RetrieveIncludeOptions, + RetrieveMemoryRequest, + RetrieveMemoryResponse, TimeRange, ) from memind.types.message import ( From 7750169ff89777cf3e0940af4114e72364321287 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Thu, 28 May 2026 18:11:44 +0800 Subject: [PATCH 54/54] fix(python): type agent timeline raw content --- memind-clients/python/src/memind/resources/async_memory.py | 4 ++-- memind-clients/python/src/memind/resources/memory.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/memind-clients/python/src/memind/resources/async_memory.py b/memind-clients/python/src/memind/resources/async_memory.py index 081aa48c..d1c4b63e 100644 --- a/memind-clients/python/src/memind/resources/async_memory.py +++ b/memind-clients/python/src/memind/resources/async_memory.py @@ -33,7 +33,7 @@ RetrieveMemoryResponse, TimeRange, ) -from memind.types.message import Message, RawContentValue +from memind.types.message import MapRawContent, Message, RawContentValue if TYPE_CHECKING: from memind._async_client import AsyncMemindClient @@ -70,7 +70,7 @@ async def extract_agent_timeline( timeline: dict[str, Any], source_client: str | None = None, ) -> ExtractMemoryResponse: - raw_content = {"type": "agent_timeline", **timeline} + raw_content = MapRawContent.model_validate({"type": "agent_timeline", **timeline}) return await self.extract( user_id=user_id, agent_id=agent_id, diff --git a/memind-clients/python/src/memind/resources/memory.py b/memind-clients/python/src/memind/resources/memory.py index 60aa0546..d64e5e8e 100644 --- a/memind-clients/python/src/memind/resources/memory.py +++ b/memind-clients/python/src/memind/resources/memory.py @@ -33,7 +33,7 @@ RetrieveMemoryResponse, TimeRange, ) -from memind.types.message import Message, RawContentValue +from memind.types.message import MapRawContent, Message, RawContentValue if TYPE_CHECKING: from memind._client import MemindClient @@ -70,7 +70,7 @@ def extract_agent_timeline( timeline: dict[str, Any], source_client: str | None = None, ) -> ExtractMemoryResponse: - raw_content = {"type": "agent_timeline", **timeline} + raw_content = MapRawContent.model_validate({"type": "agent_timeline", **timeline}) return self.extract( user_id=user_id, agent_id=agent_id,