diff --git a/.gitignore b/.gitignore index 945ab28e..4f25e79f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,34 @@ target/ ### Mac OS ### .DS_Store +### Python ### +__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 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()) 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/README.md b/memind-clients/java/README.md new file mode 100644 index 00000000..f728a3b6 --- /dev/null +++ b/memind-clients/java/README.md @@ -0,0 +1,49 @@ +# 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", + "agentTurnId", "session-123-agent-turn-1-1", + "timelineId", "session-123-agent-1-2", + "events", + List.of( + Map.of( + "eventId", "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/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 1329d536..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 @@ -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,12 +31,37 @@ 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) {} @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/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/README.md b/memind-clients/python/README.md index 8851dbe3..1a391f88 100644 --- a/memind-clients/python/README.md +++ b/memind-clients/python/README.md @@ -37,6 +37,38 @@ 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", + "agentTurnId": "session-123-agent-turn-1-1", + "timelineId": "session-123-agent-1-2", + "events": [ + { + "eventId": "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 +83,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/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 f8237f8a..d1c4b63e 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 ( @@ -23,10 +23,17 @@ CommitMemoryRequest, ExtractMemoryRequest, ExtractMemoryResponse, + MetadataFilter, + QueryMemoryItemsRequest, + QueryMemoryItemsResponse, + QueryMemoryRawDataRequest, + QueryMemoryRawDataResponse, + RetrieveIncludeOptions, RetrieveMemoryRequest, 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 @@ -55,6 +62,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 = MapRawContent.model_validate({"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, @@ -96,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"), @@ -103,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", @@ -113,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 f5f9aa6e..d64e5e8e 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 ( @@ -23,10 +23,17 @@ CommitMemoryRequest, ExtractMemoryRequest, ExtractMemoryResponse, + MetadataFilter, + QueryMemoryItemsRequest, + QueryMemoryItemsResponse, + QueryMemoryRawDataRequest, + QueryMemoryRawDataResponse, + RetrieveIncludeOptions, RetrieveMemoryRequest, 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 @@ -55,6 +62,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 = MapRawContent.model_validate({"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, @@ -96,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"), @@ -103,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 b26c5bf8..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): @@ -72,6 +130,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): @@ -85,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): @@ -139,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 343e1b93..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 @@ -87,6 +93,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( @@ -114,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 8bdefc89..749f9ad4 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, + RetrieveIncludeOptions, RetrieveMemoryRequest, Strategy, + TimeRange, ) @@ -89,6 +96,42 @@ 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", + "agentTurnId": "s-agent-turn-1-1", + "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", @@ -181,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() @@ -206,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..32d4d59d 100644 --- a/memind-clients/python/tests/test_models.py +++ b/memind-clients/python/tests/test_models.py @@ -26,8 +26,17 @@ CommitMemoryRequest, ExtractMemoryRequest, ExtractMemoryResponse, + MetadataCondition, + MetadataFilter, + QueryMemoryItemsRequest, + QueryMemoryItemsResponse, + QueryMemoryRawDataRequest, + QueryMemoryRawDataResponse, + RawDataQueryIncludeOptions, + RetrieveIncludeOptions, RetrieveMemoryRequest, RetrieveMemoryResponse, + 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/README.md b/memind-clients/typescript/README.md index f4de36b4..6f29ca91 100644 --- a/memind-clients/typescript/README.md +++ b/memind-clients/typescript/README.md @@ -76,10 +76,37 @@ 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', + agentTurnId: 'session-123-agent-turn-1-1', + timelineId: 'session-123-agent-1-2', + events: [ + { + eventId: '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-clients/typescript/src/core/validate.ts b/memind-clients/typescript/src/core/validate.ts index 48adf744..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, @@ -168,6 +172,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 } @@ -194,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 63f873d4..9afebdea 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, @@ -32,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 642e5279..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 = { @@ -64,6 +122,8 @@ export type RetrievedItem = { vectorScore: number finalScore: number occurredAt?: string + category?: string + metadata?: Record } export type RetrievedInsight = { @@ -77,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 = { @@ -132,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/src/types/message.ts b/memind-clients/typescript/src/types/message.ts index 67fb424f..17051dcc 100644 --- a/memind-clients/typescript/src/types/message.ts +++ b/memind-clients/typescript/src/types/message.ts @@ -76,7 +76,37 @@ export type JsonObjectRawContent = { [key: string]: JsonValue } -export type RawContentValue = ConversationContent | JsonObjectRawContent +export type AgentTimelineEvent = { + eventId?: 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 + agentTurnId: 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..797aac2b 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,161 @@ 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', + agentTurnId: 's-agent-turn-1-1', + 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', + agentTurnId: 's-agent-turn-1-1', + timelineId: 't', + 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/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/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/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/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/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/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 96341ae9..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))); } } } @@ -237,14 +248,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 +258,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 +275,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 +298,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 +310,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) { } @@ -320,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/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..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 @@ -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()); } @@ -415,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/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/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() { 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(); + } +} 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-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-integrations/claude-code/README.md b/memind-integrations/claude-code/README.md index 2da86adb..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 conversation messages 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 @@ -13,15 +13,19 @@ 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 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 -- **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(...)`. +- **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; + 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 @@ -99,14 +103,19 @@ 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. | -| `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. +| `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. 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. | +| `SubagentStop` | `scripts/subagent_stop.py` | 5s | Buffer subagent completion evidence for later playbook and handoff extraction. | +| `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. | + +`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 @@ -119,12 +128,10 @@ 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", - "agentIdMode": "project", + "agentId": "coding-agent", "sourceClient": "claude-code", - "ingestionMode": "extract-sync", - "preCompactCommit": true, - "commitOnSessionEnd": true, + "autoIngestAgentTimeline": true, + "autoPromptContext": false, "retrieveContextTurns": 0 } ``` @@ -143,21 +150,27 @@ 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` | `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. | -| `autoIngest` | `true` | Enables transcript ingestion during lifecycle hooks. | +| `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. | | `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. | +| `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. | +| `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. | | `ingestRetrySpool` | `true` | Enables file-backed retry for failed extraction payloads. | | `debug` | `false` | Writes debug logs to `~/.memind/claude-code.log`. | @@ -169,84 +182,240 @@ 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_MODE=project +export MEMIND_AGENT_ID=coding-agent export MEMIND_SOURCE_CLIENT=claude-code -export MEMIND_INGESTION_MODE=extract-sync -export MEMIND_PRE_COMPACT_COMMIT=true -export MEMIND_COMMIT_ON_SESSION_END=true +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 +export MEMIND_TOOL_CONTEXT_MAX_CHARS=3500 +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_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 -`MEMIND_INGEST_RETRY_MAX_AGE_DAYS`. +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`. ## Identity Model By default, Memind stores Claude Code memory under: - `userId`: `local__` -- `agentId`: `claude-code__-` +- `agentId`: `coding-agent` + +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. + +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. + +## 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 + +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. -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. +## Useful Facts +- [item:104 event, 2026-05-27] SessionStart is read-only: it queries memory and injects context without writing rawdata. + +``` + +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. + +## Prompt Context -To use one shared Claude Code memory across all projects: +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 { - "agentId": "claude-code", - "agentIdMode": "fixed" + "autoPromptContext": true, + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65 } ``` -## Retrieval Behavior - -Retrieval runs before each user prompt when `autoRetrieve = true`. +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, 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, project, 2026-05-27] Retry spool events are cleared only after successful agent_timeline extraction. + +## Agent Playbooks +- [item:203 playbook, project, 2026-05-27] When hooks change, run both integration test suites and git diff --check. + +## Tool Notes +- [item:204 tool, shared, 2026-05-18] Use Python 3.12 for the Codex integration test suite. + ## Insights -- [insight:42] ... +- [insight:301 root, project, 2026-05-27] Coding-agent integrations share memory through stable userId and agentId. ## Memory Items -- [item:101] ... +- [item:205 event, project, 2026-05-27] 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". +Agent memory items are grouped separately when returned by Memind: + +```text +## Directives +## Resolved Problems +## Agent Playbooks +## Tool Notes +``` + +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 reads Claude Code's transcript when `autoIngest = true`. +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, 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: -The ingestion flow: +```json +{ + "userId": "local__alice", + "agentId": "coding-agent", + "sourceClient": "claude-code", + "rawContent": { + "type": "agent_timeline", + "sourceClient": "claude-code", + "sessionId": "session-123", + "agentTurnId": "session-123-turn-1", + "timelineId": "session-123-turn-1-timeline", + "project": { + "name": "payment-service", + "rootPath": "/repo/payment-service", + "metadata": {"projectSlug": "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\"}", + "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} + } + ] + } +} +``` + +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 bounded outputs. -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(...)`. +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. -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. +## Server RawData Agent Settings -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`. +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 @@ -284,8 +453,7 @@ mkdir -p ~/.memind cat > ~/.memind/claude-code.json <<'JSON' { "userId": "local__memind-smoke", - "agentId": "claude-code-smoke", - "agentIdMode": "fixed", + "agentId": "coding-agent-smoke", "sourceClient": "claude-code", "debug": true } @@ -309,16 +477,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 }' ``` -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. +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: @@ -400,33 +568,34 @@ 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. -### 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 -- 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. +- `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/hooks/hooks.json b/memind-integrations/claude-code/hooks/hooks.json index 1bc0e40e..90c5538a 100644 --- a/memind-integrations/claude-code/hooks/hooks.json +++ b/memind-integrations/claude-code/hooks/hooks.json @@ -22,6 +22,53 @@ ] } ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/pre_tool_use.py\"", + "timeout": 5 + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/post_tool_use.py\"", + "timeout": 5, + "async": true + } + ] + } + ], + "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 cd04271b..1c659c14 100644 --- a/memind-integrations/claude-code/scripts/ingest.py +++ b/memind-integrations/claude-code/scripts/ingest.py @@ -22,8 +22,14 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from lib.client import MemindClient +from lib.agent_timeline import ( + build_timeline_payload, + normalize_assistant_message_event, + normalize_session_end_event, + normalize_stop_event, +) from lib.config import load_config -from lib.content import extract_messages +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 @@ -31,26 +37,21 @@ 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" -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: +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( { @@ -59,59 +60,118 @@ def _spool_extract(retry_spool, identity, source_client, session_id, messages): "agentId": identity["agentId"], "sourceClient": source_client, "sessionId": session_id, - "fingerprints": [message["fingerprint"] for message in messages], - "rawContent": _extract_payload(messages), + "eventIds": [event["eventId"] for event in events if event.get("eventId")], + "rawContent": raw_content, } ) -async def ingest_messages_async(config, hook_input, commit=False, max_messages=None): +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 + + +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 == "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) - 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 + submitted_turn_id = None 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 + 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() if submitted_turn_id else [] + else: + agent_events = [] + 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"], - _extract_payload(selected), + timeline_payload, source_client, ) except Exception: - _spool_extract(retry_spool, identity, source_client, session_id, selected) + _spool_agent_timeline( + retry_spool, + identity, + source_client, + session_id, + agent_events, + timeline_payload, + ) else: status = getattr(response, "status", None) if status == "SUCCESS": - submitted = [message["fingerprint"] for message in selected] + agent_events_submitted = len(agent_events) + state.clear_agent_events( + [event["eventId"] for event in agent_events if event.get("eventId")] + ) + state.close_agent_turn(submitted_turn_id) else: - _spool_extract(retry_spool, identity, source_client, session_id, selected) - state.mark_submitted(submitted) - committed = False - return {"submitted": len(submitted), "committed": committed} + _spool_agent_timeline( + retry_spool, + identity, + source_client, + session_id, + agent_events, + timeline_payload, + ) + 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/agent_timeline.py b/memind-integrations/claude-code/scripts/lib/agent_timeline.py new file mode 100644 index 00000000..eefd36f5 --- /dev/null +++ b/memind-integrations/claude-code/scripts/lib/agent_timeline.py @@ -0,0 +1,696 @@ +# +# 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 + +try: + from .identity import project_slug +except ImportError: + from lib.identity import project_slug + + +MAX_TEXT_CHARS = 4000 +NORMALIZATION_VERSION = 1 + +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)), +] + +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) + 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 _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 [] + 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 "" + 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, + "kind": kind or "", + "textHash": hashlib.sha256((text or "").encode("utf-8")).hexdigest(), + }, + sort_keys=True, + ) + return hashlib.sha256(stable.encode("utf-8")).hexdigest() + + +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"), + "sessionId": session_id, + "sourceClient": source_client, + } + 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_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" + tool_name = hook_input.get("tool_name") + 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), + "seq": seq, + "kind": event_kind, + "occurredAt": hook_input.get("timestamp"), + "toolName": tool_name, + } + 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(raw_tool_input if raw_tool_input is not None else {}) + 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 = _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, + "sourceClient": source_client, + } + 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: + metadata["turnSeq"] = turn_seq + 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 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 + 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"{agent_turn_id}-timeline", + "events": list(events), + "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")], + }, + } + if turn_id: + payload["metadata"]["turnId"] = turn_id + if turn_seq is not None: + payload["metadata"]["turnSeq"] = turn_seq + if cwd: + path = Path(cwd) + slug = project_slug(path) + payload["project"] = { + "name": path.name, + "rootPath": str(path), + "metadata": {"projectSlug": slug}, + } + payload["metadata"]["projectSlug"] = slug + 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/client.py b/memind-integrations/claude-code/scripts/lib/client.py index 4ddd6081..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,4 +104,84 @@ 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( + 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 8286ea1f..776cc340 100644 --- a/memind-integrations/claude-code/scripts/lib/config.py +++ b/memind-integrations/claude-code/scripts/lib/config.py @@ -20,22 +20,28 @@ "memindApiUrl": "http://127.0.0.1:8366", "memindApiToken": None, "userId": None, - "agentId": "claude-code", - "agentIdMode": "project", + "agentId": "coding-agent", "sourceClient": "claude-code", "autoRetrieve": True, - "autoIngest": True, + "autoPromptContext": False, + "autoSessionContext": 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, + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65, + "autoToolContext": True, + "toolContextMaxChars": 3500, + "toolContextEntryMaxChars": 520, + "toolContextMaxItems": 6, + "toolContextMinExactItems": 2, + "sessionContextRecentSessions": 3, + "sessionContextMaxItems": 6, + "sessionContextMaxChars": 6000, "stateMaxAgeDays": 14, "ingestRetrySpool": True, "ingestRetryMaxFiles": 20, @@ -48,18 +54,26 @@ "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": ("autoIngest", "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_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_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"), + "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"), "MEMIND_STATE_MAX_AGE_DAYS": ("stateMaxAgeDays", "int"), "MEMIND_INGEST_RETRY_SPOOL": ("ingestRetrySpool", "bool"), "MEMIND_INGEST_RETRY_MAX_FILES": ("ingestRetryMaxFiles", "int"), @@ -79,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()] @@ -90,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/content.py b/memind-integrations/claude-code/scripts/lib/content.py index 894e61be..41d866e2 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: @@ -127,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/context_compiler.py b/memind-integrations/claude-code/scripts/lib/context_compiler.py new file mode 100644 index 00000000..6785c24c --- /dev/null +++ b/memind-integrations/claude-code/scripts/lib/context_compiler.py @@ -0,0 +1,619 @@ +# +# 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, +} + +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 = { + "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=_prompt_attrs(data), + 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=_prompt_attrs(data), + 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 _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")] + 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() + 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), + "source": _field(item, "memindContextSource"), + } + + +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, + "source": _field(insight, "memindContextSource"), + } + + +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] + 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'}" + source = entry.get("source") + label_parts = [label] + if source: + label_parts.append(source) + if date: + label_parts.append(date) + label = ", ".join(label_parts) + 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 + + 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("") + 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/scripts/lib/identity.py b/memind-integrations/claude-code/scripts/lib/identity.py index c36c4fa6..8ac610a5 100644 --- a/memind-integrations/claude-code/scripts/lib/identity.py +++ b/memind-integrations/claude-code/scripts/lib/identity.py @@ -50,11 +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" - if config.get("agentIdMode") == "project": - agent_id = f"{base_agent}{SEPARATOR}{project_slug(cwd)}" - else: - agent_id = base_agent + agent_id = config.get("agentId") or "coding-agent" return {"userId": user_id, "agentId": agent_id} 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/lib/session_context.py b/memind-integrations/claude-code/scripts/lib/session_context.py new file mode 100644 index 00000000..02241b48 --- /dev/null +++ b/memind-integrations/claude-code/scripts/lib/session_context.py @@ -0,0 +1,125 @@ +# +# 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. +# + +from lib.context_compiler import compile_session_start_context + +DEFAULT_RECENT_SESSIONS = 3 +DEFAULT_MAX_ITEMS = 6 +DEFAULT_MAX_CHARS = 6000 + + +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): + return compile_session_start_context(context, config) + + +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 _field(value, name): + if isinstance(value, dict): + return value.get(name) + return getattr(value, name, None) + + +def _text(item): + return _field(item, "text") diff --git a/memind-integrations/claude-code/scripts/lib/state.py b/memind-integrations/claude-code/scripts/lib/state.py index d06614d4..4e51ca23 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): @@ -29,16 +30,79 @@ 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) + self.data.setdefault("nextAgentTurnSeq", 1) - def is_submitted(self, fingerprint): - return fingerprint in set(self.data.get("submitted", [])) + def append_agent_event(self, event): + events = list(self.data.get("agentEvents", [])) + 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: + 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() + + 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("eventId") 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 + + 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 mark_submitted(self, fingerprints): - submitted = set(self.data.get("submitted", [])) - submitted.update(fingerprints) - self.data["submitted"] = sorted(submitted) + 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() + + def is_empty(self): + return ( + not self.data.get("agentEvents") + and not self.data.get("currentAgentTurnId") + and not self.data.get("currentAgentTurnSeq") + ) class SessionStateStore: 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..799f847e --- /dev/null +++ b/memind-integrations/claude-code/scripts/lib/tool_context.py @@ -0,0 +1,246 @@ +# +# 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"} +TOOL_CONTEXT_CATEGORIES = ["resolution", "tool", "playbook", "directive"] + + +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 "" + + +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/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/post_tool_use.py b/memind-integrations/claude-code/scripts/post_tool_use.py new file mode 100644 index 00000000..c916adea --- /dev/null +++ b/memind-integrations/claude-code/scripts/post_tool_use.py @@ -0,0 +1,49 @@ +#!/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: + 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) + 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_compact.py b/memind-integrations/claude-code/scripts/pre_compact.py index 267a80f2..478926e0 100644 --- a/memind-integrations/claude-code/scripts/pre_compact.py +++ b/memind-integrations/claude-code/scripts/pre_compact.py @@ -19,21 +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, - commit=bool(config.get("preCompactCommit", True)), - max_messages=int(config.get("preCompactMaxMessages", 20)), - ) + 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/scripts/pre_tool_use.py b/memind-integrations/claude-code/scripts/pre_tool_use.py new file mode 100644 index 00000000..0b414d96 --- /dev/null +++ b/memind-integrations/claude-code/scripts/pre_tool_use.py @@ -0,0 +1,95 @@ +#!/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 +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 "{}") + 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})) + + +if __name__ == "__main__": + main() diff --git a/memind-integrations/claude-code/scripts/retrieve.py b/memind-integrations/claude-code/scripts/retrieve.py index 7e9980ef..d0911eb6 100644 --- a/memind-integrations/claude-code/scripts/retrieve.py +++ b/memind-integrations/claude-code/scripts/retrieve.py @@ -16,71 +16,63 @@ import json import os import sys +from pathlib import Path 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.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 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} + return compile_prompt_retrieval_context(data, config) - 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)] +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 + ) + ) - sections = [] - if selected_insights: - sections.append("## Insights") - sections.extend(f"- [insight:{insight.get('id')}] {insight.get('text')}" for insight in selected_insights) - if selected_items: - if sections: - sections.append("") - sections.append("## Memory Items") - sections.extend(f"- [item:{item.get('id')}] {item.get('text')}" for item in selected_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" + 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() - 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}" - 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/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 d885d895..f94acdee 100644 --- a/memind-integrations/claude-code/scripts/session_start.py +++ b/memind-integrations/claude-code/scripts/session_start.py @@ -25,11 +25,18 @@ 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 +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" @@ -38,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: @@ -53,7 +84,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,20 +94,9 @@ 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"): + if payload.get("sessionId") and payload.get("eventIds"): with SessionStateStore(state_root()).locked(payload["sessionId"]) as state: - state.mark_submitted(payload["fingerprints"]) - 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"]]) + state.clear_agent_events(payload["eventIds"]) spool.complete(claimed) elif payload.get("kind") == "commit": await replay_client.commit(payload["userId"], payload["agentId"], payload.get("sourceClient")) @@ -95,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/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 f1db3037..9ead56d2 100644 --- a/memind-integrations/claude-code/settings.json +++ b/memind-integrations/claude-code/settings.json @@ -2,22 +2,28 @@ "memindApiUrl": "http://127.0.0.1:8366", "memindApiToken": null, "userId": null, - "agentId": "claude-code", - "agentIdMode": "project", + "agentId": "coding-agent", "sourceClient": "claude-code", "autoRetrieve": true, - "autoIngest": true, + "autoPromptContext": false, + "autoSessionContext": 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, + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65, + "autoToolContext": true, + "toolContextMaxChars": 3500, + "toolContextEntryMaxChars": 520, + "toolContextMaxItems": 6, + "toolContextMinExactItems": 2, + "sessionContextRecentSessions": 3, + "sessionContextMaxItems": 6, + "sessionContextMaxChars": 6000, "stateMaxAgeDays": 14, "ingestRetrySpool": true, "ingestRetryMaxFiles": 20, 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..315b1447 --- /dev/null +++ b/memind-integrations/claude-code/tests/test_agent_timeline.py @@ -0,0 +1,481 @@ +# +# 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 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, + 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, +) + + +class AgentTimelineTest(unittest.TestCase): + def test_normalizes_post_tool_use_to_test_result_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, + turn_id="s-turn-1", + turn_seq=1, + ) + + self.assertEqual(event["kind"], "test_result") + 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") + 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"]["sessionId"], "s") + self.assertEqual(event["metadata"]["sourceClient"], "claude-code") + 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_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( + { + "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( + { + "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"]["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): + 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"]["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( + { + "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": "git status --short"}, + "tool_response": {"exit_code": 0}, + "timestamp": "2026-05-24T10:00:00Z", + }, + seq=1, + turn_id="s-turn-2", + turn_seq=2, + ) + + 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["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") + 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__": + unittest.main() diff --git a/memind-integrations/claude-code/tests/test_client.py b/memind-integrations/claude-code/tests/test_client.py index 6e2fcdd9..1400826b 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,74 @@ 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, 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: + 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.MetadataFilter = _MetadataFilter + module.RetrieveIncludeOptions = _RetrieveIncludeOptions + module.TimeRange = _TimeRange + module.QueryMemoryItemsRequest = _QueryMemoryItemsRequest + module.QueryMemoryRawDataRequest = _QueryMemoryRawDataRequest + module.RawDataQueryIncludeOptions = _RawDataQueryIncludeOptions return module @@ -163,6 +234,66 @@ 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() + 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 ba24906a..61107e44 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): @@ -41,10 +43,27 @@ 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["ingestionMode"], "extract-sync") self.assertEqual(DEFAULT_SETTINGS["sourceClient"], "claude-code") - self.assertEqual(DEFAULT_SETTINGS["ingestionMaxMessagesPerHook"], 20) + 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) + 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) + self.assertNotIn("ingestionRoles", DEFAULT_SETTINGS) + self.assertNotIn("ingestionMaxMessagesPerHook", DEFAULT_SETTINGS) self.assertEqual(DEFAULT_SETTINGS["stateMaxAgeDays"], 14) def test_environment_overrides(self): @@ -54,16 +73,67 @@ def test_environment_overrides(self): env = { "MEMIND_API_URL": "http://memind.example", "MEMIND_AUTO_RETRIEVE": "false", - "MEMIND_INGESTION_ROLES": "user,assistant", + "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", } with patch.dict(os.environ, env, clear=False): 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.assertEqual(config["ingestionRoles"], ["user", "assistant"]) + 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) + + 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) + + 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__": 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_context_compiler.py b/memind-integrations/claude-code/tests/test_context_compiler.py new file mode 100644 index 00000000..181ca4d4 --- /dev/null +++ b/memind-integrations/claude-code/tests/test_context_compiler.py @@ -0,0 +1,371 @@ +# +# 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.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, "") + + 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) + + 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 + + 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/claude-code/tests/test_hooks.py b/memind-integrations/claude-code/tests/test_hooks.py index 1f106ee5..566bb8fc 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 @@ -60,9 +61,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")) @@ -72,6 +73,152 @@ 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.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_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)} @@ -90,6 +237,245 @@ 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"], "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("', 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, 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) + 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/claude-code/tests/test_state.py b/memind-integrations/claude-code/tests/test_state.py index 5901d2b8..69d7e138 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)) @@ -44,6 +35,59 @@ 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_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({"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(), [{"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)) + with store.locked("session-1") as state: + for index in range(501): + 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]["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__": unittest.main() 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..8bfad43c --- /dev/null +++ b/memind-integrations/claude-code/tests/test_tool_context.py @@ -0,0 +1,249 @@ +# +# 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 +from types import SimpleNamespace + +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 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( + { + "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") + + 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/README.md b/memind-integrations/codex/README.md index 17530c49..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 conversation messages 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. @@ -12,15 +12,18 @@ 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 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 -- **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(...)`. -- **Retry**: failed ingestion batches are spooled under `~/.memind/codex/retry/` and replayed on the next +- **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. +- **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. @@ -118,12 +121,18 @@ 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. | -| `UserPromptSubmit` | `scripts/retrieve.py` | 12s | Retrieve relevant Memind context for the current user prompt. | -| `Stop` | `scripts/ingest.py` | 15s | Submit new Codex transcript messages through reliable extraction. | +| `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. 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. | -`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. +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. + +`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 @@ -136,11 +145,10 @@ User configuration is optional. Save overrides as `~/.memind/codex.json`: "memindApiUrl": "http://127.0.0.1:8366", "memindApiToken": null, "userId": "local__alice", - "agentId": "codex", - "agentIdMode": "project", + "agentId": "coding-agent", "sourceClient": "codex", - "ingestionMode": "extract-sync", - "commitOnStop": false, + "autoIngestAgentTimeline": true, + "autoPromptContext": false, "retrieveContextTurns": 0 } ``` @@ -158,19 +166,27 @@ 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` | `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. | -| `autoIngest` | `true` | Enables transcript ingestion after Codex turns. | -| `commitOnStop` | `false` | Compatibility flag for server-buffer ingestion mode; ignored by the default reliable mode. | +| `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. | | `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. | +| `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. | +| `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. | | `ingestRetrySpool` | `true` | Enables file-backed retry for failed ingestion. | | `debug` | `false` | Writes debug logs to `~/.memind/codex.log`. | @@ -182,17 +198,25 @@ 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_MODE=project +export MEMIND_AGENT_ID=coding-agent export MEMIND_SOURCE_CLIENT=codex -export MEMIND_COMMIT_ON_STOP=false +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 +export MEMIND_TOOL_CONTEXT_MAX_CHARS=3500 +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_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_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`. ## Identity Model @@ -200,63 +224,213 @@ Additional environment variables include `MEMIND_AUTO_RETRIEVE`, `MEMIND_AUTO_IN By default, Memind stores Codex memory under: - `userId`: `local__` -- `agentId`: `codex__-` +- `agentId`: `coding-agent` + +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. + +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. + +## 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 + +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. -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. +## Useful Facts +- [item:104 event, 2026-05-27] SessionStart is read-only: it queries memory and injects context without writing rawdata. + +``` + +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. + +## Prompt Context -To use one shared Codex memory across all projects: +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 { - "agentId": "codex", - "agentIdMode": "fixed" + "autoPromptContext": true, + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65 } ``` -## Retrieval Behavior - -Retrieval runs before each user prompt when `autoRetrieve = true`. +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, 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, project, 2026-05-27] Retry spool events are cleared only after successful agent_timeline extraction. + +## Agent Playbooks +- [item:203 playbook, project, 2026-05-27] When hooks change, run both integration test suites and git diff --check. + +## Tool Notes +- [item:204 tool, shared, 2026-05-18] Use Python 3.12 for the Codex integration test suite. + ## Insights -- [insight:42] ... +- [insight:301 root, project, 2026-05-27] Coding-agent integrations share memory through stable userId and agentId. ## Memory Items -- [item:101] ... +- [item:205 event, project, 2026-05-27] 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". +Agent memory items are grouped separately when returned by Memind: + +```text +## Directives +## Resolved Problems +## Agent Playbooks +## Tool Notes +``` + +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 runs after each Codex turn when `autoIngest = true`. +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: -The Stop hook: +```json +{ + "userId": "local__alice", + "agentId": "coding-agent", + "sourceClient": "codex", + "rawContent": { + "type": "agent_timeline", + "sourceClient": "codex", + "sessionId": "session-123", + "agentTurnId": "session-123-turn-1", + "timelineId": "session-123-turn-1-timeline", + "project": { + "name": "payment-service", + "rootPath": "/repo/payment-service", + "metadata": {"projectSlug": "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\"}", + "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} + } + ] + } +} +``` + +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 bounded outputs. -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(...)`. +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. -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. +## Server RawData Agent Settings -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`. +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 @@ -360,33 +534,31 @@ 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. -### 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 -- 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. +- `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. -- `commitOnStop` applies only to compatibility server-buffer ingestion mode and is ignored by the default - reliable extraction mode. 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..3d00e4c1 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)] @@ -177,16 +180,23 @@ 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/context_compiler.py" "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" + "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/scripts/ingest.py b/memind-integrations/codex/scripts/ingest.py index ca843bc0..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, + normalize_assistant_message_event, + normalize_stop_event, +) from lib.config import load_config -from lib.content import extract_messages +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 @@ -32,26 +37,21 @@ 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" -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: +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( { @@ -60,49 +60,98 @@ def _spool_extract(retry_spool, identity, source_client, session_key, messages): "agentId": identity["agentId"], "sourceClient": source_client, "sessionKey": session_key, - "fingerprints": [message["fingerprint"] for message in messages], - "rawContent": _extract_payload(messages), + "eventIds": [event["eventId"] for event in events if event.get("eventId")], + "rawContent": raw_content, } ) +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) - 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 + submitted_turn_id = None with store.locked(session_key) as state: - selected = [message for message in messages if not state.is_submitted(message["fingerprint"])][:limit] - - submitted = [] - if selected: + 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( + config, + identity, + session_key, + agent_events, + hook_input, + ) try: response = await client.extract( identity["userId"], identity["agentId"], - _extract_payload(selected), + timeline_payload, source_client, ) except Exception: - _spool_extract(retry_spool, identity, source_client, session_key, selected) + _spool_agent_timeline( + retry_spool, + identity, + source_client, + session_key, + agent_events, + timeline_payload, + ) else: status = getattr(response, "status", None) if status == "SUCCESS": - submitted = [message["fingerprint"] for message in selected] - store.mark_submitted(session_key, submitted) + agent_events_submitted = len(agent_events) + 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_extract(retry_spool, identity, source_client, session_key, selected) - - return {"submitted": len(submitted), "committed": False} + _spool_agent_timeline( + retry_spool, + identity, + source_client, + session_key, + agent_events, + timeline_payload, + ) + + return {"agentEventsSubmitted": agent_events_submitted, "committed": False} def ingest_messages(config, hook_input): 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..ff930569 --- /dev/null +++ b/memind-integrations/codex/scripts/lib/agent_timeline.py @@ -0,0 +1,642 @@ +# +# 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 + +try: + from .identity import project_slug +except ImportError: + from lib.identity import project_slug + + +MAX_TEXT_CHARS = 4000 +NORMALIZATION_VERSION = 1 + +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)), +] + +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) + 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 _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 [] + 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 "" + 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, + "kind": kind or "", + "textHash": hashlib.sha256((text or "").encode("utf-8")).hexdigest(), + }, + sort_keys=True, + ) + return hashlib.sha256(stable.encode("utf-8")).hexdigest() + + +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"), + "sessionId": session_id, + "sourceClient": source_client, + } + 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_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" + tool_name = hook_input.get("tool_name") + 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), + "seq": seq, + "kind": event_kind, + "occurredAt": hook_input.get("timestamp"), + "toolName": tool_name, + } + 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(raw_tool_input if raw_tool_input is not None else {}) + 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 = _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, + "sourceClient": source_client, + } + 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: + metadata["turnSeq"] = turn_seq + 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 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 + 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"{agent_turn_id}-timeline", + "events": list(events), + "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")], + }, + } + if turn_id: + payload["metadata"]["turnId"] = turn_id + if turn_seq is not None: + payload["metadata"]["turnSeq"] = turn_seq + if cwd: + path = Path(cwd) + slug = project_slug(path) + payload["project"] = { + "name": path.name, + "rootPath": str(path), + "metadata": {"projectSlug": slug}, + } + payload["metadata"]["projectSlug"] = slug + 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/client.py b/memind-integrations/codex/scripts/lib/client.py index efcdf4f7..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,4 +103,84 @@ 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( + 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 47d47eec..ab9de4d7 100644 --- a/memind-integrations/codex/scripts/lib/config.py +++ b/memind-integrations/codex/scripts/lib/config.py @@ -20,20 +20,28 @@ "memindApiUrl": "http://127.0.0.1:8366", "memindApiToken": None, "userId": None, - "agentId": "codex", - "agentIdMode": "project", + "agentId": "coding-agent", "sourceClient": "codex", "autoRetrieve": True, - "autoIngest": True, - "commitOnStop": False, + "autoPromptContext": False, + "autoSessionContext": 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, + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65, + "autoToolContext": True, + "toolContextMaxChars": 3500, + "toolContextEntryMaxChars": 520, + "toolContextMaxItems": 6, + "toolContextMinExactItems": 2, + "sessionContextRecentSessions": 3, + "sessionContextMaxItems": 6, + "sessionContextMaxChars": 6000, "stateMaxAgeDays": 14, "ingestRetrySpool": True, "ingestRetryMaxFiles": 20, @@ -46,18 +54,26 @@ "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": ("autoIngest", "bool"), - "MEMIND_COMMIT_ON_STOP": ("commitOnStop", "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_INGESTION_MODE": ("ingestionMode", str), - "MEMIND_INGESTION_ROLES": ("ingestionRoles", "list"), - "MEMIND_INGESTION_MAX_MESSAGES_PER_HOOK": ("ingestionMaxMessagesPerHook", "int"), + "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"), + "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"), "MEMIND_STATE_MAX_AGE_DAYS": ("stateMaxAgeDays", "int"), "MEMIND_INGEST_RETRY_SPOOL": ("ingestRetrySpool", "bool"), "MEMIND_INGEST_RETRY_MAX_FILES": ("ingestRetryMaxFiles", "int"), @@ -77,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()] @@ -88,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/content.py b/memind-integrations/codex/scripts/lib/content.py index 2d20d39e..e3cf7326 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: @@ -181,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/context_compiler.py b/memind-integrations/codex/scripts/lib/context_compiler.py new file mode 100644 index 00000000..6785c24c --- /dev/null +++ b/memind-integrations/codex/scripts/lib/context_compiler.py @@ -0,0 +1,619 @@ +# +# 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, +} + +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 = { + "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=_prompt_attrs(data), + 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=_prompt_attrs(data), + 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 _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")] + 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() + 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), + "source": _field(item, "memindContextSource"), + } + + +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, + "source": _field(insight, "memindContextSource"), + } + + +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] + 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'}" + source = entry.get("source") + label_parts = [label] + if source: + label_parts.append(source) + if date: + label_parts.append(date) + label = ", ".join(label_parts) + 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 + + 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("") + 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/scripts/lib/identity.py b/memind-integrations/codex/scripts/lib/identity.py index b5c59981..8ac610a5 100644 --- a/memind-integrations/codex/scripts/lib/identity.py +++ b/memind-integrations/codex/scripts/lib/identity.py @@ -50,11 +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" - if config.get("agentIdMode") == "project": - agent_id = f"{base_agent}{SEPARATOR}{project_slug(cwd)}" - else: - agent_id = base_agent + agent_id = config.get("agentId") or "coding-agent" return {"userId": user_id, "agentId": agent_id} 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/lib/session_context.py b/memind-integrations/codex/scripts/lib/session_context.py new file mode 100644 index 00000000..b6769046 --- /dev/null +++ b/memind-integrations/codex/scripts/lib/session_context.py @@ -0,0 +1,125 @@ +# +# 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. +# + +from lib.context_compiler import compile_session_start_context + +DEFAULT_RECENT_SESSIONS = 3 +DEFAULT_MAX_ITEMS = 6 +DEFAULT_MAX_CHARS = 6000 + +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): + return compile_session_start_context(context, config) + + +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 _field(value, name): + if isinstance(value, dict): + return value.get(name) + return getattr(value, name, None) + + +def _text(item): + return _field(item, "text") + diff --git a/memind-integrations/codex/scripts/lib/state.py b/memind-integrations/codex/scripts/lib/state.py index 92ca70bb..bc77572e 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): @@ -35,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()))}" @@ -88,16 +86,79 @@ 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) + self.data.setdefault("nextAgentTurnSeq", 1) + + def append_agent_event(self, event): + events = list(self.data.get("agentEvents", [])) + 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: + 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() - def is_submitted(self, fingerprint): - return fingerprint in set(self.data.get("submitted", [])) + 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("eventId") not in event_ids + ] + self.data["updatedAt"] = time.time() - def mark_submitted(self, fingerprints): - submitted = set(self.data.get("submitted", [])) - submitted.update(fingerprints) - self.data["submitted"] = sorted(submitted) + def next_agent_seq(self): + seq = int(self.data.get("nextAgentSeq", 1)) + self.data["nextAgentSeq"] = seq + 1 + 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() + + def is_empty(self): + return ( + not self.data.get("agentEvents") + and not self.data.get("currentAgentTurnId") + and not self.data.get("currentAgentTurnSeq") + ) class SessionStateStore: @@ -132,9 +193,9 @@ def locked(self, session_key): yield state self._write(path, state.data) - def mark_submitted(self, session_key, fingerprints): + def clear_agent_events(self, session_key, event_ids): with self.locked(session_key) as state: - state.mark_submitted(fingerprints) + state.clear_agent_events(event_ids) def cleanup(self, max_age_days): cutoff = time.time() - max_age_days * 86400 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..799f847e --- /dev/null +++ b/memind-integrations/codex/scripts/lib/tool_context.py @@ -0,0 +1,246 @@ +# +# 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"} +TOOL_CONTEXT_CATEGORIES = ["resolution", "tool", "playbook", "directive"] + + +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 "" + + +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/scripts/post_tool_use.py b/memind-integrations/codex/scripts/post_tool_use.py new file mode 100644 index 00000000..6050f9a9 --- /dev/null +++ b/memind-integrations/codex/scripts/post_tool_use.py @@ -0,0 +1,49 @@ +#!/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: + 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) + 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..baf35aa5 --- /dev/null +++ b/memind-integrations/codex/scripts/pre_tool_use.py @@ -0,0 +1,95 @@ +#!/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 +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 "{}") + 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})) + + +if __name__ == "__main__": + main() diff --git a/memind-integrations/codex/scripts/retrieve.py b/memind-integrations/codex/scripts/retrieve.py index b1132e71..69d13692 100644 --- a/memind-integrations/codex/scripts/retrieve.py +++ b/memind-integrations/codex/scripts/retrieve.py @@ -17,71 +17,63 @@ import json import os import sys +from pathlib import Path 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.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 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} + return compile_prompt_retrieval_context(data, config) - 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)] +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 + ) + ) - sections = [] - if selected_insights: - sections.append("## Insights") - sections.extend(f"- [insight:{insight.get('id')}] {insight.get('text')}" for insight in selected_insights) - if selected_items: - if sections: - sections.append("") - sections.append("## Memory Items") - sections.extend(f"- [item:{item.get('id')}] {item.get('text')}" for item in selected_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" + 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() - 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}" - 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/scripts/session_start.py b/memind-integrations/codex/scripts/session_start.py index b3e9d833..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 @@ -39,60 +41,57 @@ 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) - return len(fingerprints) - if kind == "ingestion-batch": - return await _replay_ingestion_batch(client, payload) + event_ids = payload.get("eventIds") or [] + if session_key and event_ids: + SessionStateStore(state_root()).clear_agent_events(session_key, event_ids) + return 0 if kind == "commit": await client.commit(payload["userId"], payload["agentId"], payload.get("sourceClient")) return 0 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: @@ -101,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 @@ -125,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 22b59be0..d0c04c45 100644 --- a/memind-integrations/codex/settings.json +++ b/memind-integrations/codex/settings.json @@ -2,20 +2,28 @@ "memindApiUrl": "http://127.0.0.1:8366", "memindApiToken": null, "userId": null, - "agentId": "codex", - "agentIdMode": "project", + "agentId": "coding-agent", "sourceClient": "codex", "autoRetrieve": true, - "autoIngest": true, - "commitOnStop": false, + "autoPromptContext": false, + "autoSessionContext": 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, + "promptContextProjectMinEntries": 4, + "promptContextGlobalFallbackEntries": 3, + "promptContextGlobalFallbackMinScore": 0.65, + "autoToolContext": true, + "toolContextMaxChars": 3500, + "toolContextEntryMaxChars": 520, + "toolContextMaxItems": 6, + "toolContextMinExactItems": 2, + "sessionContextRecentSessions": 3, + "sessionContextMaxItems": 6, + "sessionContextMaxChars": 6000, "stateMaxAgeDays": 14, "ingestRetrySpool": true, "ingestRetryMaxFiles": 20, 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..e839daef --- /dev/null +++ b/memind-integrations/codex/tests/test_agent_timeline.py @@ -0,0 +1,454 @@ +# +# 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 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, + normalize_assistant_message_event, + normalize_compact_boundary_event, + normalize_hook_event, + normalize_session_end_event, + normalize_stop_event, + normalize_user_prompt_event, +) + + +class AgentTimelineTest(unittest.TestCase): + def test_normalizes_post_tool_use_to_test_result_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, + turn_id="s-turn-1", + turn_seq=1, + ) + + self.assertEqual(event["kind"], "test_result") + 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") + 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"]["sessionId"], "s") + self.assertEqual(event["metadata"]["sourceClient"], "codex") + 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_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( + { + "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( + { + "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"]["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): + 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"]["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( + { + "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": "git status --short"}, + "tool_response": {"exit_code": 0}, + "source_client": "codex", + }, + seq=1, + turn_id="s-turn-2", + turn_seq=2, + ) + + 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["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") + 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__": + unittest.main() diff --git a/memind-integrations/codex/tests/test_client.py b/memind-integrations/codex/tests/test_client.py index 1a7f1042..eea76a91 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,74 @@ 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, 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: + 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.MetadataFilter = _MetadataFilter + module.RetrieveIncludeOptions = _RetrieveIncludeOptions + module.TimeRange = _TimeRange + module.QueryMemoryItemsRequest = _QueryMemoryItemsRequest + module.QueryMemoryRawDataRequest = _QueryMemoryRawDataRequest + module.RawDataQueryIncludeOptions = _RawDataQueryIncludeOptions return module @@ -155,6 +226,65 @@ 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() + 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 b8837047..36e76add 100644 --- a/memind-integrations/codex/tests/test_config.py +++ b/memind-integrations/codex/tests/test_config.py @@ -17,26 +17,42 @@ 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): 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.assertFalse(config["commitOnStop"]) 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) + 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) 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", + "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,15 +61,59 @@ 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.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) def test_parse_helpers(self): self.assertTrue(parse_bool("yes")) 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) + + 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_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_context_compiler.py b/memind-integrations/codex/tests/test_context_compiler.py new file mode 100644 index 00000000..b98e5d46 --- /dev/null +++ b/memind-integrations/codex/tests/test_context_compiler.py @@ -0,0 +1,377 @@ +# +# 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.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) + + 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 + + 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/tests/test_hooks.py b/memind-integrations/codex/tests/test_hooks.py index 18e6b9a3..0473fe43 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 @@ -60,9 +61,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")) @@ -72,6 +73,152 @@ 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.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_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 = { @@ -88,175 +235,349 @@ 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_ingest_uses_extract_sync_and_ignores_commit_flag_in_reliable_mode(self): + 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"], "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 ingest + 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.NamedTemporaryFile("w", delete=False) as handle: - handle.write(json.dumps({"role": "user", "content": "remember this"}) + "\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: + 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("', 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, 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) + 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() diff --git a/memind-integrations/codex/tests/test_state.py b/memind-integrations/codex/tests/test_state.py index 2446f415..18d15795 100644 --- a/memind-integrations/codex/tests/test_state.py +++ b/memind-integrations/codex/tests/test_state.py @@ -25,32 +25,70 @@ 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_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_marks_and_loads_submitted_fingerprints(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: - state.mark_submitted(["a", "b"]) + self.assertEqual(state.next_agent_seq(), 1) + self.assertEqual(state.next_agent_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.assertTrue(state.is_submitted("a")) - self.assertFalse(state.is_submitted("c")) + self.assertEqual(state.agent_events(), [{"eventId": "e2", "seq": 2}]) - def test_mark_submitted_persists_immediately(self): + def test_state_reports_empty_after_all_events_are_cleared_and_turn_closed(self): with tempfile.TemporaryDirectory() as tmp: store = SessionStateStore(Path(tmp)) - store.mark_submitted("session-1", ["a"]) + 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)) + with store.locked("session-1") as state: + for index in range(501): + 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]["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: - self.assertTrue(state.is_submitted("a")) + 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: 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/tests/test_tool_context.py b/memind-integrations/codex/tests/test_tool_context.py new file mode 100644 index 00000000..8bfad43c --- /dev/null +++ b/memind-integrations/codex/tests/test_tool_context.py @@ -0,0 +1,249 @@ +# +# 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 +from types import SimpleNamespace + +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 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( + { + "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") + + 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-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..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 @@ -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,68 @@ 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", + "session-1-agent-turn-1-2", + "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 { 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 = 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/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..8c779419 --- /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,301 @@ +/* + * 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 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; + +/** + * 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 generate(content, metadata, null); + } + + @Override + public Mono generate(String content, Map metadata, String language) { + 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 deterministicCaption(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 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")); + String command = first(metadata.get("commands")); + if (!file.isBlank() && !command.isBlank()) { + parts.add(file + "; " + command); + } else if (!file.isBlank()) { + parts.add(file); + } else if (!command.isBlank()) { + parts.add(command); + } + String failureSignal = first(metadata.get("failureSignals")); + if (!failureSignal.isBlank()) { + parts.add(failureSignal); + } + 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) { + 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 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 ""; + } + 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/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..cc825fec --- /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,371 @@ +/* + * 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.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 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(); + } + return buildBaseEpisodes(timeline, sorted(timeline.events())); + } + + private List buildBaseEpisodes( + AgentTimelineContent timeline, List events) { + List episodes = new ArrayList<>(); + List current = new ArrayList<>(); + AgentEvent previous = null; + String currentBoundaryKey = null; + + for (AgentEvent event : events) { + boolean startsNewPrompt = + event.kind() == AgentEventKind.USER_PROMPT && !current.isEmpty(); + boolean crossesBoundary = + !current.isEmpty() + && (startsNewPrompt + || fallbackBoundaryExceeded( + currentBoundaryKey, previous, event, current) + || boundaryKeyChanged(currentBoundaryKey, boundaryKey(event))); + if (crossesBoundary) { + episodes.add(buildEpisode(timeline, current, "full", Map.of())); + current = new ArrayList<>(); + currentBoundaryKey = null; + } + + current.add(event); + if (currentBoundaryKey == null) { + currentBoundaryKey = boundaryKey(event); + } + previous = event; + + if (isTerminal(event)) { + episodes.add(buildEpisode(timeline, current, "full", Map.of())); + current = new ArrayList<>(); + currentBoundaryKey = 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::eventId).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 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.eventId())) + .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.eventId())) + .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.eventId())) + .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 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 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) + && !currentBoundaryKey.equals(nextBoundaryKey); + } + + 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) { + 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.SYNTHETIC_BOUNDARY + || 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::eventId, + 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..7963e025 --- /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,256 @@ +/* + * 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.AgentFileReference; +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.eventId() + ": " + 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("agentTurnId", timeline.agentTurnId()); + 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()); + } + 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())); + } + 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()); + 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()); + } + if (episode.endTime() != null) { + metadata.put("windowEnd", episode.endTime()); + } + metadata.putAll(episode.metadata()); + 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 + ? 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(); + } + + 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 { + 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..1cb7a4ea --- /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,85 @@ +/* + * 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.agentTurnId(), + 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/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/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/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/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/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/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..9a2a5b57 --- /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,309 @@ +/* + * 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 agentTurnId; + 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 agentTurnId, + String timelineId, + AgentProject project, + List events) { + this( + sourceClient, + sourceVersion, + sessionId, + agentTurnId, + timelineId, + project, + events, + Map.of()); + } + + @JsonCreator + 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, + @JsonProperty("metadata") Map metadata) { + this.sourceClient = sourceClient; + this.sourceVersion = sourceVersion; + this.sessionId = sessionId; + this.agentTurnId = agentTurnId; + 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, "Agent Turn", agentTurnId); + 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::eventId).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(agentTurnId), + normalized(timelineId), + eventIds, + normalized(eventHash))); + } + + @Override + public Map contentMetadata() { + return metadata; + } + + @Override + public RawContent withMetadata(Map metadata) { + return new AgentTimelineContent( + sourceClient, + sourceVersion, + sessionId, + agentTurnId, + 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("agentTurnId") + public String agentTurnId() { + return agentTurnId; + } + + @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::eventId, + 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.eventId() != null && !event.eventId().isBlank()) { + parts.add(event.eventId()); + } + 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.eventId()), + 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.inputTokens()), + normalized(event.outputTokens()), + normalized(event.contentHash()), + 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/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..5b004e38 --- /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,376 @@ +/* + * 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.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.EnumSet; +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; + +/** + * 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; + private final AgentMemoryItemFactory memoryItemFactory; + + public AgentItemExtractionStrategy() { + this(null, PromptRegistry.EMPTY, AgentExtractionOptions.defaults()); + } + + 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 + public Mono> extract( + List segments, + List insightTypes, + ItemExtractionConfig config) { + if (segments == null || segments.isEmpty()) { + return Mono.just(List.of()); + } + return Flux.fromIterable(segments) + .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 + ? 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( + categories, + allowed, + MemoryCategory.PLAYBOOK, + options.extractPlaybook() + && (eventCount >= options.minEventsForPlaybook() + || hasHighSignalSubagentEvidence(segment.metadata())) + && (!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 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) { + 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, + insightTypes(item), + 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; + } + List expectedInsightTypes = expectedInsightTypes(category); + if (expectedInsightTypes.isEmpty() + || item.insightTypes() == null + || item.insightTypes().stream().noneMatch(expectedInsightTypes::contains)) { + 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, "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"); + 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 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 "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(); + }; + } + + 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; + } + + public PromptRegistry promptRegistry() { + return 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/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..446b150f --- /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,140 @@ +/* + * 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 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}}. + + 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: + - 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. + - 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": [ + "identity|preferences|relationships|behavior|experiences|tools|resolutions|playbooks|directives" + ], + "metadata": { + "evidenceEventIds": ["event-id"], + "...": "category-specific fields" + }, + "category": "profile|behavior|event|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/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..f34fb34e --- /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,424 @@ +/* + * 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"); + 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"); + 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()); + 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()) { + 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." + .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/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/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..fa241ab0 --- /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,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.model; + +import java.time.Instant; +import java.util.Map; + +/** + * One normalized event from an agent session timeline. + */ +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) { + + 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..9a6c3738 --- /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,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.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_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 + 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/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/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/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/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/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/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..3dcb2c18 --- /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(context.chatClientRegistry().defaultClient()), + 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/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..9be8352b --- /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,226 @@ +/* + * 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.eventId(), + event.seq(), + event.kind(), + event.occurredAt(), + text, + event.toolName(), + input, + output, + event.status(), + event.durationMs(), + event.inputTokens(), + event.outputTokens(), + event.contentHash(), + 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/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..f067a7b0 --- /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,95 @@ +/* + * 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.EnumSet; +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 EnumSet.allOf(MemoryCategory.class); + } + + @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/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/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..d18b5bbc --- /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,227 @@ +/* + * 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 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 { + + @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)"); + } + + @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 = + 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"); + } + + 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/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..06451d35 --- /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,636 @@ +/* + * 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 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()); + 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 shouldKeepExplicitTurnTogetherAcrossLongGap() { + 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), + 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 = + new AgentEpisodeAssembler() + .assemble(AgentEpisodeTestSupport.paymentTimeline(events)); + + assertThat(episodes).hasSize(2); + assertThat(episodes.getFirst().eventIds()).containsExactly("e1"); + assertThat(episodes.get(1).eventIds()).containsExactly("e2"); + } + + @Test + void shouldNotSplitExplicitTurnWhenEventCountExceedsMax() { + 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(1); + assertThat(episodes.getFirst().eventIds()).containsExactly("e1", "e2", "e3", "e4", "e5"); + } + + @Test + void shouldNotSplitExplicitTurnWhenEpisodeExceedsTargetTokens() { + AgentChunkingOptions options = new AgentChunkingOptions(20, 40, 80, Duration.ofMinutes(30)); + + List episodes = + new AgentEpisodeAssembler(options) + .assemble( + AgentEpisodeTestSupport.paymentTimeline( + AgentEpisodeTestSupport.paymentEvents())); + + 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 + 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"); + } + + @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"); + } + + @Test + void shouldCloseEpisodesOnStopButKeepCompactBoundaryInsideTurn() { + 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 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( + 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).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"); + } + + 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.eventId(), + base.seq(), + base.kind(), + base.occurredAt(), + base.text(), + base.toolName(), + base.input(), + base.output(), + base.status(), + base.durationMs(), + base.inputTokens(), + base.outputTokens(), + base.contentHash(), + base.path(), + base.operation(), + base.command(), + 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.inputTokens(), + base.outputTokens(), + base.contentHash(), + base.path(), + base.operation(), + base.command(), + base.exitCode(), + Map.of("turnId", turnId)); + } +} 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..6a88502c --- /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,146 @@ +/* + * 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", + "session-123-agent-turn-1-5", + "timeline-123", + new AgentProject( + "payments-api", + "/Users/alice/work/payments-api", + null, + Map.of("projectSlug", "payments-api-remote")), + 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, + null, + null, + null, + 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..327b718f --- /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,273 @@ +/* + * 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.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; + +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("projectId", "payments-api-remote") + .containsEntry("projectSlug", "payments-api-remote") + .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().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().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 new file mode 100644 index 00000000..cd0214f2 --- /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,73 @@ +/* + * 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, + null, + 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"); + } +} 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..733c76fa --- /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,373 @@ +/* + * 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, + null, + 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, + null, + null, + null, + Map.of())); + + AgentTimelineContent content = + new AgentTimelineContent( + "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", + "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::eventId).containsExactly("e1", "e2"); + } + + @Test + void jacksonRoundTripShouldPreserveSubtypeAndUserPromptText() throws Exception { + AgentTimelineContent content = + new AgentTimelineContent( + "codex", + "1.0", + "session-1", + "session-1-agent-turn-1-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, + 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(json).contains("\"eventId\":\"e1\""); + assertThat(json).doesNotContain("\"id\":\"e1\""); + 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"); + } + + @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"); + } + + @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")) + .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, + null, + 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, + 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, + null, + 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/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..d5868b39 --- /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,619 @@ +/* + * 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.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; +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(2); + assertThat(client.captionCalls()).isEqualTo(1); + assertThat(client.itemExtractionCalls()).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 agentPipelineAllowsUserAndAgentCategoriesFromTimeline() { + 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)) + .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) + .contains(MemoryCategory.TOOL, MemoryCategory.RESOLUTION, MemoryCategory.PROFILE); + } + + @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", + "session-123-agent-turn-1-5", + "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, + null, + null, + null, + 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 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 + public Mono call(List messages) { + return Mono.error(new UnsupportedOperationException("not used by this test")); + } + + @Override + @SuppressWarnings("unchecked") + public Mono call(List messages, Class responseType) { + structuredCalls++; + 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 { + + 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()); + } + } +} 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..f1ebeafb --- /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,479 @@ +/* + * 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") + .containsEntry("projectId", "payments-api-remote") + .containsEntry("projectSlug", "payments-api-remote") + .containsEntry("projectName", "payments-api"); + 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 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 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 = + 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("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")), + 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 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( + 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 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; + 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; + } + } +} 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..6ba4bfec --- /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,409 @@ +/* + * 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("projectId", "payments-api-remote") + .containsEntry("projectSlug", "payments-api-remote") + .containsEntry("projectName", "payments-api"); + assertThat(entry.metadata()) + .containsEntry("command", "npm test payment"); + 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"); + }); + 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 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 = + 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("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")), + 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"))), + 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")))))); + } + + 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 segment("Goal: Fix payment tests", metadata); + } + + private static ParsedSegment segment(String text, Map metadata) { + return new ParsedSegment( + text, + 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"); + } +} 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/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..8870b15e --- /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,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. + */ +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, + null, + 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, + null, + null, + null, + "/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, + null, + null, + null, + "/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"); + } + + @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", + 1, + AgentEventKind.COMMAND, + Instant.parse("2026-05-24T10:00:00Z"), + null, + "Bash", + null, + output, + AgentEventStatus.SUCCESS, + 42L, + null, + null, + null, + 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()); + } +} 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..9308183b --- /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()) + .containsExactlyInAnyOrder(MemoryCategory.values()); + assertThat(processor.usesSourceIdentity()).isTrue(); + assertThat(processor.supportsInsight()).isTrue(); + assertThat(processor.itemExtractionStrategy()) + .isInstanceOf(AgentItemExtractionStrategy.class); + assertThat(processor.captionGenerator()).isInstanceOf(AgentCaptionGenerator.class); + } +} 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 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..aa1697b5 --- /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,135 @@ +/* + * 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", + "agentTurnId": "s-agent-turn-1-1", + "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/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 fe0bc026..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 @@ -15,6 +15,7 @@ import java.time.Instant; import java.util.List; +import java.util.Map; public record RetrieveMemoryResponse( String status, @@ -38,10 +39,46 @@ 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) {} 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 cefa4337..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 @@ -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,15 @@ 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 (!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()); @@ -68,6 +78,18 @@ public PageResponse page(ItemPageQuery query) { 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); @@ -116,7 +138,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 +151,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/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 213abaef..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(), @@ -336,6 +426,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/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/AgentTimelineOpenApiIntegrationTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/AgentTimelineOpenApiIntegrationTest.java new file mode 100644 index 00000000..82444cbe --- /dev/null +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/AgentTimelineOpenApiIntegrationTest.java @@ -0,0 +1,472 @@ +/* + * 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", + "agentTurnId": "s-agent-turn-1-5", + "timelineId": "t", + "project": { + "name": "payments-api", + "rootPath": "/Users/alice/work/payments-api" + }, + "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", + "metadata": {"failureSignal": "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" + } + ] + } + } + """; + } + + @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) {} + } +} 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..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 @@ -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,43 @@ 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", + "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", + "command": "mvn test", + "exitCode": 0, + "status": "SUCCESS" + } + ] + } + } + """)) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.data.status").value("accepted")); } } 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 43050e78..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; @@ -164,7 +166,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,11 +189,67 @@ 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); } + @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(); @@ -350,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 @@ -432,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