awareness-memory-cloud 2.4.4__tar.gz → 2.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/PKG-INFO +15 -1
  2. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/README.md +14 -0
  3. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/awareness_memory_cloud.egg-info/PKG-INFO +15 -1
  4. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/awareness_memory_cloud.egg-info/SOURCES.txt +3 -1
  5. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/memory_cloud/client.py +82 -7
  6. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/memory_cloud/types.py +11 -2
  7. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/pyproject.toml +1 -1
  8. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/tests/test_export_reader.py +5 -2
  9. awareness_memory_cloud-2.6.0/tests/test_hyde.py +200 -0
  10. awareness_memory_cloud-2.6.0/tests/test_skill_types.py +86 -0
  11. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/LICENSE +0 -0
  12. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/awareness_memory_cloud.egg-info/dependency_links.txt +0 -0
  13. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/awareness_memory_cloud.egg-info/requires.txt +0 -0
  14. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/awareness_memory_cloud.egg-info/top_level.txt +0 -0
  15. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/memory_cloud/__init__.py +0 -0
  16. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/memory_cloud/errors.py +0 -0
  17. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/memory_cloud/export_reader.py +0 -0
  18. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/memory_cloud/integrations/__init__.py +0 -0
  19. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/memory_cloud/integrations/_base.py +0 -0
  20. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/memory_cloud/integrations/autogen.py +0 -0
  21. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/memory_cloud/integrations/crewai.py +0 -0
  22. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/memory_cloud/integrations/langchain.py +0 -0
  23. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/memory_cloud/integrations/praisonai.py +0 -0
  24. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/memory_cloud/interceptor.py +0 -0
  25. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/memory_cloud/query_rewrite.py +0 -0
  26. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/memory_cloud/quickstart.py +0 -0
  27. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/setup.cfg +0 -0
  28. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/tests/test_client_chat_and_record.py +0 -0
  29. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/tests/test_client_crud.py +0 -0
  30. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/tests/test_client_extraction_passthrough.py +0 -0
  31. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/tests/test_client_recall_and_compaction.py +0 -0
  32. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/tests/test_client_tasks_agents_users.py +0 -0
  33. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/tests/test_client_upload_export_keys.py +0 -0
  34. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/tests/test_ecosystem_smoke.py +0 -0
  35. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/tests/test_integrations.py +0 -0
  36. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/tests/test_interceptor_response_format_fallback.py +0 -0
  37. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/tests/test_interceptor_wrap.py +0 -0
  38. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/tests/test_quickstart.py +0 -0
  39. {awareness_memory_cloud-2.4.4 → awareness_memory_cloud-2.6.0}/tests/test_sdk_functional.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: awareness-memory-cloud
3
- Version: 2.4.4
3
+ Version: 2.6.0
4
4
  Summary: Python SDK for Awareness Memory Cloud
5
5
  Author: Awareness Team
6
6
  License: Apache License
@@ -638,3 +638,17 @@ export AWARENESS_OWNER_ID="your-owner-id"
638
638
 
639
639
  python examples/e2e_langchain_cloud.py
640
640
  ```
641
+
642
+ ---
643
+
644
+ ## What makes Awareness different
645
+
646
+ Most memory systems pick one extraction strategy. Awareness combines them:
647
+
648
+ - **Hybrid retrieval by default** — BM25 full-text + vector cosine + knowledge-graph 1-hop expansion, fused with Reciprocal Rank Fusion. 95.6% R@5 on LongMemEval, zero LLM calls on the retrieval side.
649
+ - **Salience-aware extraction** — the client's own LLM self-scores every card on `novelty` / `durability` / `specificity`; cards below 0.4 on novelty or durability are dropped server-side. Framework metadata (`Sender (untrusted metadata)`, `turn_brief`) is filtered before extraction runs.
650
+ - **Project isolation** — `X-Awareness-Project-Dir` header scopes memory per project.
651
+ - **Zero-LLM backend** — all extraction runs on your LLM (Claude, GPT-4, Gemini, local Llama). The backend is a coordinator + storage layer; no inference costs pass through to you.
652
+ - **One memory, many clients** — same data reachable via Claude Code, OpenClaw, npm / pip / ClawHub, MCP server.
653
+
654
+ See [`docs/analysis/MEMPALACE_COMPARISON_2026-04-17.md`](https://github.com/edwin-hao-ai/Awareness/blob/main/docs/analysis/MEMPALACE_COMPARISON_2026-04-17.md) for the honest side-by-side against MemPalace.
@@ -389,3 +389,17 @@ export AWARENESS_OWNER_ID="your-owner-id"
389
389
 
390
390
  python examples/e2e_langchain_cloud.py
391
391
  ```
392
+
393
+ ---
394
+
395
+ ## What makes Awareness different
396
+
397
+ Most memory systems pick one extraction strategy. Awareness combines them:
398
+
399
+ - **Hybrid retrieval by default** — BM25 full-text + vector cosine + knowledge-graph 1-hop expansion, fused with Reciprocal Rank Fusion. 95.6% R@5 on LongMemEval, zero LLM calls on the retrieval side.
400
+ - **Salience-aware extraction** — the client's own LLM self-scores every card on `novelty` / `durability` / `specificity`; cards below 0.4 on novelty or durability are dropped server-side. Framework metadata (`Sender (untrusted metadata)`, `turn_brief`) is filtered before extraction runs.
401
+ - **Project isolation** — `X-Awareness-Project-Dir` header scopes memory per project.
402
+ - **Zero-LLM backend** — all extraction runs on your LLM (Claude, GPT-4, Gemini, local Llama). The backend is a coordinator + storage layer; no inference costs pass through to you.
403
+ - **One memory, many clients** — same data reachable via Claude Code, OpenClaw, npm / pip / ClawHub, MCP server.
404
+
405
+ See [`docs/analysis/MEMPALACE_COMPARISON_2026-04-17.md`](https://github.com/edwin-hao-ai/Awareness/blob/main/docs/analysis/MEMPALACE_COMPARISON_2026-04-17.md) for the honest side-by-side against MemPalace.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: awareness-memory-cloud
3
- Version: 2.4.4
3
+ Version: 2.6.0
4
4
  Summary: Python SDK for Awareness Memory Cloud
5
5
  Author: Awareness Team
6
6
  License: Apache License
@@ -638,3 +638,17 @@ export AWARENESS_OWNER_ID="your-owner-id"
638
638
 
639
639
  python examples/e2e_langchain_cloud.py
640
640
  ```
641
+
642
+ ---
643
+
644
+ ## What makes Awareness different
645
+
646
+ Most memory systems pick one extraction strategy. Awareness combines them:
647
+
648
+ - **Hybrid retrieval by default** — BM25 full-text + vector cosine + knowledge-graph 1-hop expansion, fused with Reciprocal Rank Fusion. 95.6% R@5 on LongMemEval, zero LLM calls on the retrieval side.
649
+ - **Salience-aware extraction** — the client's own LLM self-scores every card on `novelty` / `durability` / `specificity`; cards below 0.4 on novelty or durability are dropped server-side. Framework metadata (`Sender (untrusted metadata)`, `turn_brief`) is filtered before extraction runs.
650
+ - **Project isolation** — `X-Awareness-Project-Dir` header scopes memory per project.
651
+ - **Zero-LLM backend** — all extraction runs on your LLM (Claude, GPT-4, Gemini, local Llama). The backend is a coordinator + storage layer; no inference costs pass through to you.
652
+ - **One memory, many clients** — same data reachable via Claude Code, OpenClaw, npm / pip / ClawHub, MCP server.
653
+
654
+ See [`docs/analysis/MEMPALACE_COMPARISON_2026-04-17.md`](https://github.com/edwin-hao-ai/Awareness/blob/main/docs/analysis/MEMPALACE_COMPARISON_2026-04-17.md) for the honest side-by-side against MemPalace.
@@ -28,8 +28,10 @@ tests/test_client_tasks_agents_users.py
28
28
  tests/test_client_upload_export_keys.py
29
29
  tests/test_ecosystem_smoke.py
30
30
  tests/test_export_reader.py
31
+ tests/test_hyde.py
31
32
  tests/test_integrations.py
32
33
  tests/test_interceptor_response_format_fallback.py
33
34
  tests/test_interceptor_wrap.py
34
35
  tests/test_quickstart.py
35
- tests/test_sdk_functional.py
36
+ tests/test_sdk_functional.py
37
+ tests/test_skill_types.py
@@ -333,6 +333,7 @@ class MemoryCloudClient:
333
333
  include_installed: bool = True,
334
334
  detail: Optional[str] = None,
335
335
  ids: Optional[List[str]] = None,
336
+ hyde_hint: Optional[str] = None,
336
337
  trace_id: Optional[str] = None,
337
338
  ) -> Dict[str, Any]:
338
339
  """Retrieve from memory using the specified recall mode.
@@ -419,24 +420,42 @@ class MemoryCloudClient:
419
420
  body["detail"] = detail
420
421
  if ids is not None:
421
422
  body["ids"] = ids
423
+ if hyde_hint and hyde_hint.strip():
424
+ body["hyde_hint"] = hyde_hint.strip()
422
425
 
423
426
  # Local daemon bridge: route through awareness_recall MCP tool.
427
+ # F-053 single-parameter surface — pass only `query` and let the daemon
428
+ # auto-route (query-type classifier + recency channel + budget-tier
429
+ # shaping). Legacy fields are still forwarded for backwards-compat with
430
+ # pre-F-053 daemons but marked explicitly so server-side deprecation
431
+ # logging can trace who's still sending them.
424
432
  if self._should_use_daemon():
425
433
  daemon_args: Dict[str, Any] = {
426
- "semantic_query": query,
434
+ "query": query,
427
435
  "limit": limit,
428
- "detail": detail or "summary",
429
436
  }
430
- if effective_keyword:
437
+ # Token budget drives bucket shaping (raw-heavy / mixed / card-only).
438
+ # Callers can override via custom_kwargs["token_budget"].
439
+ token_budget = (custom_kwargs or {}).get("token_budget")
440
+ if token_budget is not None:
441
+ daemon_args["token_budget"] = int(token_budget)
442
+ if agent_role:
443
+ daemon_args["agent_role"] = agent_role
444
+ # Legacy fields — only forwarded if caller explicitly passed them.
445
+ # Daemon will log [deprecated param used] for these.
446
+ if effective_keyword and keyword_query is not None:
431
447
  daemon_args["keyword_query"] = effective_keyword
432
448
  if scope:
433
449
  daemon_args["scope"] = scope
434
- if recall_mode:
450
+ if recall_mode and recall_mode != "precise":
451
+ # "precise" is the SDK default — don't forward it as legacy.
435
452
  daemon_args["recall_mode"] = recall_mode
436
- if agent_role:
437
- daemon_args["agent_role"] = agent_role
453
+ if detail is not None:
454
+ daemon_args["detail"] = detail
438
455
  if ids:
439
456
  daemon_args["ids"] = ids
457
+ if hyde_hint and hyde_hint.strip():
458
+ daemon_args["hyde_hint"] = hyde_hint.strip()
440
459
  daemon_result = self.call_local_daemon("awareness_recall", daemon_args)
441
460
  items = daemon_result.get("items") or daemon_result.get("results") or []
442
461
  return {"results": items}
@@ -449,6 +468,50 @@ class MemoryCloudClient:
449
468
  )
450
469
  return self._attach_trace(payload, resolved_trace_id)
451
470
 
471
+ def retrieve_with_hyde(
472
+ self,
473
+ memory_id: str,
474
+ query: str,
475
+ llm_complete: Callable[[str], str],
476
+ **kwargs: Any,
477
+ ) -> Dict[str, Any]:
478
+ """Retrieve with a user-supplied LLM generating a HyDE
479
+ (Hypothetical Document Embeddings) hint.
480
+
481
+ The caller passes a ``llm_complete`` callable that wraps their own
482
+ LLM provider (OpenAI, Anthropic, ollama, etc.). If the LLM call
483
+ fails or returns a too-short string, this method falls back to a
484
+ plain ``retrieve()`` with no ``hyde_hint`` — HyDE is always an
485
+ optional boost, never required.
486
+
487
+ :param memory_id: Target memory id.
488
+ :param query: The raw user query.
489
+ :param llm_complete: Sync callable taking a prompt string and
490
+ returning a completion string. Any raised exception is
491
+ swallowed and causes fallback to plain retrieve.
492
+ :param kwargs: Forwarded verbatim to ``retrieve()`` (e.g.
493
+ ``limit``, ``recall_mode``, ``user_id`` …). Do not pass
494
+ ``hyde_hint`` here — it is generated by this helper.
495
+ """
496
+ hyde_prompt = (
497
+ "Write a single paragraph (100-200 characters) hypothetical "
498
+ "answer to this question, as if it were already in a knowledge "
499
+ "base. No preamble, no markdown.\n\n"
500
+ f"Question: {query}"
501
+ )
502
+ hyde_hint: Optional[str] = None
503
+ try:
504
+ generated = llm_complete(hyde_prompt)
505
+ if generated and len(generated.strip()) >= 20:
506
+ hyde_hint = generated.strip()[:400]
507
+ except Exception:
508
+ # Any LLM error → fall back to raw retrieve (no hyde_hint)
509
+ pass
510
+ # Strip a caller-supplied hyde_hint to avoid duplicate-kwarg TypeError
511
+ # if they passed one accidentally; the helper's generated value wins.
512
+ kwargs.pop("hyde_hint", None)
513
+ return self.retrieve(memory_id, query, hyde_hint=hyde_hint, **kwargs)
514
+
452
515
  def write(
453
516
  self,
454
517
  memory_id: str,
@@ -690,6 +753,10 @@ class MemoryCloudClient:
690
753
  result["events_sent"] = len(capped)
691
754
  if ingest_result.get("trace_id"):
692
755
  result["trace_id"] = ingest_result["trace_id"]
756
+ # Hoist extraction_request to top-level so callers can see that
757
+ # extraction was scheduled without reaching into result["ingest"].
758
+ if ingest_result.get("extraction_request") is not None:
759
+ result["extraction_request"] = ingest_result["extraction_request"]
693
760
 
694
761
  # --- Submit insights directly ---
695
762
  if insights is not None:
@@ -1521,22 +1588,30 @@ class MemoryCloudClient:
1521
1588
  self,
1522
1589
  memory_id: str,
1523
1590
  skill_id: str,
1591
+ outcome: Optional[str] = "success",
1524
1592
  trace_id: Optional[str] = None,
1525
1593
  ) -> Dict[str, Any]:
1526
1594
  """
1527
- Mark a skill as used, resetting its decay timer.
1595
+ Mark a skill as used with outcome feedback.
1528
1596
 
1529
1597
  Args:
1530
1598
  memory_id: Target memory id.
1531
1599
  skill_id: The skill to mark as used.
1600
+ outcome: "success" (default), "partial", or "failed".
1601
+ Failed outcomes decrease confidence; 3+ consecutive
1602
+ failures auto-flag the skill for review.
1532
1603
  trace_id: Optional trace id.
1533
1604
 
1534
1605
  Returns:
1535
1606
  Updated skill data.
1536
1607
  """
1608
+ body = None
1609
+ if outcome and outcome != "success":
1610
+ body = {"outcome": outcome}
1537
1611
  data, resolved_trace = self._request(
1538
1612
  method="POST",
1539
1613
  path=f"/memories/{memory_id}/skills/{skill_id}/use",
1614
+ json_payload=body,
1540
1615
  trace_id=trace_id,
1541
1616
  )
1542
1617
  return self._attach_trace(data, resolved_trace)
@@ -1,4 +1,4 @@
1
- from typing import Any, Dict, List, Optional, TypedDict
1
+ from typing import Any, Dict, List, Literal, Optional, TypedDict
2
2
 
3
3
 
4
4
  class RetrieveResult(TypedDict, total=False):
@@ -423,7 +423,12 @@ class Skill(TypedDict, total=False):
423
423
  trigger_conditions: List[SkillTrigger]
424
424
  tags: List[str]
425
425
  source_card_ids: List[str]
426
- growth_stage: str # seedling | budding | evergreen
426
+ # Lifecycle stage. Kept as plain str for legacy rows, but the typed union
427
+ # is exposed via `GrowthStage` for call sites that want narrower typing.
428
+ growth_stage: Literal["seedling", "budding", "evergreen"]
429
+ # F-059 — known failure modes + post-run checks. Optional for back-compat.
430
+ pitfalls: List[str]
431
+ verification: List[str]
427
432
  usage_count: int
428
433
  decay_score: float
429
434
  pinned: bool
@@ -434,6 +439,10 @@ class Skill(TypedDict, total=False):
434
439
  last_used_at: str
435
440
 
436
441
 
442
+ #: Exposed for consumers that want to type-narrow growth_stage.
443
+ GrowthStage = Literal["seedling", "budding", "evergreen"]
444
+
445
+
437
446
  class SkillListResult(TypedDict, total=False):
438
447
  skills: List[Skill]
439
448
  total: int
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "awareness-memory-cloud"
7
- version = "2.4.4"
7
+ version = "2.6.0"
8
8
  description = "Python SDK for Awareness Memory Cloud"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -40,10 +40,13 @@ def test_mcp_helper_session_and_history_parse():
40
40
  second = client._begin_memory_session(memory_id="m1", source="python-sdk")
41
41
  assert first["session_id"] != second["session_id"]
42
42
 
43
- backfilled = client._coerce_history_to_events(
44
- history="User: hi\nAssistant: done",
43
+ # v2.0.0 removed _coerce_history_to_events; equivalent behaviour is now
44
+ # `_build_record_events` accepting a list of transcript lines.
45
+ backfilled = client._build_record_events(
46
+ ["User: hi", "Assistant: done"],
45
47
  source="python-sdk",
46
48
  session_id=second["session_id"],
49
+ scope="timeline",
47
50
  )
48
51
  assert len(backfilled) == 2
49
52
  assert backfilled[0]["session_id"] == second["session_id"]
@@ -0,0 +1,200 @@
1
+ """Tests for ``retrieve_with_hyde`` helper on the Python SDK.
2
+
3
+ Covers the four behaviours we care about:
4
+ 1. Happy path: LLM returns a sensible hypothetical answer → it is
5
+ trimmed, clamped to 400 chars, and forwarded to ``retrieve`` as the
6
+ ``hyde_hint`` kwarg.
7
+ 2. Fallback: ``llm_complete`` raises → the helper swallows the error
8
+ and calls ``retrieve`` with ``hyde_hint=None``.
9
+ 3. Too-short output (<20 chars after strip) is dropped.
10
+ 4. Plain ``retrieve(hyde_hint="…")`` wires the value into the POST
11
+ body so the new backend parameter is actually on the wire.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Dict
17
+
18
+ import pytest
19
+
20
+ from memory_cloud.client import MemoryCloudClient
21
+
22
+
23
+ def _make_client() -> MemoryCloudClient:
24
+ return MemoryCloudClient(
25
+ base_url="http://localhost:8000/api/v1",
26
+ api_key="test-key",
27
+ )
28
+
29
+
30
+ def test_retrieve_with_hyde_forwards_generated_hint() -> None:
31
+ client = _make_client()
32
+
33
+ captured: Dict[str, Any] = {}
34
+ prompt_seen: Dict[str, str] = {}
35
+
36
+ def llm_complete(prompt: str) -> str:
37
+ prompt_seen["value"] = prompt
38
+ return (
39
+ "JWT refresh tokens live for 30 days, rotate on every use, "
40
+ "and are stored hashed in users.refresh_token_hash."
41
+ )
42
+
43
+ def fake_retrieve(*args: Any, **kwargs: Any) -> Dict[str, Any]:
44
+ # Support either positional or keyword passthrough
45
+ if args:
46
+ kwargs.setdefault("memory_id", args[0] if len(args) > 0 else None)
47
+ kwargs.setdefault("query", args[1] if len(args) > 1 else None)
48
+ captured.update(kwargs)
49
+ return {"results": [{"content": "hit"}]}
50
+
51
+ client.retrieve = fake_retrieve # type: ignore[method-assign]
52
+
53
+ out = client.retrieve_with_hyde(
54
+ "memory-1",
55
+ "How long do refresh tokens live?",
56
+ llm_complete,
57
+ limit=5,
58
+ )
59
+
60
+ assert out["results"] == [{"content": "hit"}]
61
+ assert captured["memory_id"] == "memory-1"
62
+ assert captured["query"] == "How long do refresh tokens live?"
63
+ assert captured["limit"] == 5
64
+ assert isinstance(captured["hyde_hint"], str)
65
+ assert captured["hyde_hint"].startswith("JWT refresh tokens live")
66
+ assert "hypothetical answer" in prompt_seen["value"]
67
+ assert "How long do refresh tokens live?" in prompt_seen["value"]
68
+
69
+
70
+ def test_retrieve_with_hyde_falls_back_when_llm_raises() -> None:
71
+ client = _make_client()
72
+ captured: Dict[str, Any] = {}
73
+
74
+ def llm_complete(_prompt: str) -> str:
75
+ raise RuntimeError("LLM provider down")
76
+
77
+ def fake_retrieve(*args: Any, **kwargs: Any) -> Dict[str, Any]:
78
+ if args:
79
+ kwargs.setdefault("memory_id", args[0] if len(args) > 0 else None)
80
+ kwargs.setdefault("query", args[1] if len(args) > 1 else None)
81
+ captured.update(kwargs)
82
+ return {"results": []}
83
+
84
+ client.retrieve = fake_retrieve # type: ignore[method-assign]
85
+
86
+ client.retrieve_with_hyde(
87
+ "memory-2",
88
+ "fallback query",
89
+ llm_complete,
90
+ )
91
+
92
+ assert captured["query"] == "fallback query"
93
+ assert captured["hyde_hint"] is None, (
94
+ "When the user's LLM raises, we must fall back to plain retrieve "
95
+ "with hyde_hint=None, not crash or silently send a partial hint."
96
+ )
97
+
98
+
99
+ def test_retrieve_with_hyde_drops_too_short_output() -> None:
100
+ client = _make_client()
101
+ captured: Dict[str, Any] = {}
102
+
103
+ def llm_complete(_prompt: str) -> str:
104
+ return "too short" # 9 chars < 20 threshold
105
+
106
+ def fake_retrieve(*args: Any, **kwargs: Any) -> Dict[str, Any]:
107
+ if args:
108
+ kwargs.setdefault("memory_id", args[0] if len(args) > 0 else None)
109
+ kwargs.setdefault("query", args[1] if len(args) > 1 else None)
110
+ captured.update(kwargs)
111
+ return {"results": []}
112
+
113
+ client.retrieve = fake_retrieve # type: ignore[method-assign]
114
+
115
+ client.retrieve_with_hyde("memory-3", "short-output q", llm_complete)
116
+
117
+ assert captured["hyde_hint"] is None
118
+
119
+
120
+ def test_retrieve_with_hyde_clamps_long_output() -> None:
121
+ client = _make_client()
122
+ captured: Dict[str, Any] = {}
123
+
124
+ def llm_complete(_prompt: str) -> str:
125
+ return "A" * 1200
126
+
127
+ def fake_retrieve(*args: Any, **kwargs: Any) -> Dict[str, Any]:
128
+ if args:
129
+ kwargs.setdefault("memory_id", args[0] if len(args) > 0 else None)
130
+ kwargs.setdefault("query", args[1] if len(args) > 1 else None)
131
+ captured.update(kwargs)
132
+ return {"results": []}
133
+
134
+ client.retrieve = fake_retrieve # type: ignore[method-assign]
135
+
136
+ client.retrieve_with_hyde("memory-4", "clamp check", llm_complete)
137
+
138
+ assert isinstance(captured["hyde_hint"], str)
139
+ assert len(captured["hyde_hint"]) == 400
140
+
141
+
142
+ def test_retrieve_wires_hyde_hint_into_post_body() -> None:
143
+ """Direct retrieve(hyde_hint=…) must forward the field on the wire."""
144
+ client = _make_client()
145
+ captured: Dict[str, Any] = {}
146
+
147
+ def fake_request(
148
+ *,
149
+ method: str,
150
+ path: str,
151
+ json_payload: Any = None,
152
+ trace_id: Any = None,
153
+ **_: Any,
154
+ ):
155
+ captured["method"] = method
156
+ captured["path"] = path
157
+ captured["body"] = json_payload
158
+ return ({"results": []}, trace_id or "trace-x")
159
+
160
+ client._request = fake_request # type: ignore[method-assign]
161
+
162
+ client.retrieve(
163
+ "memory-5",
164
+ "explicit hint",
165
+ hyde_hint=" A pre-computed hypothetical passage for the query. ",
166
+ )
167
+
168
+ assert captured["method"] == "POST"
169
+ assert captured["path"] == "/memories/memory-5/retrieve"
170
+ assert (
171
+ captured["body"]["hyde_hint"]
172
+ == "A pre-computed hypothetical passage for the query."
173
+ ), "Explicit hyde_hint must be trimmed and sent on the wire"
174
+
175
+
176
+ def test_retrieve_omits_hyde_hint_when_not_set() -> None:
177
+ """Backward compat: callers who do not pass hyde_hint see no new field."""
178
+ client = _make_client()
179
+ captured: Dict[str, Any] = {}
180
+
181
+ def fake_request(
182
+ *,
183
+ method: str,
184
+ path: str,
185
+ json_payload: Any = None,
186
+ trace_id: Any = None,
187
+ **_: Any,
188
+ ):
189
+ captured["body"] = json_payload
190
+ return ({"results": []}, trace_id or "trace-y")
191
+
192
+ client._request = fake_request # type: ignore[method-assign]
193
+
194
+ client.retrieve("memory-6", "no hint here")
195
+
196
+ assert "hyde_hint" not in captured["body"]
197
+
198
+
199
+ if __name__ == "__main__": # pragma: no cover
200
+ pytest.main([__file__, "-v"])
@@ -0,0 +1,86 @@
1
+ """Unit tests for `Skill` TypedDict shape (F-059 additions).
2
+
3
+ Ensures that `pitfalls`, `verification`, and `growth_stage` can be populated
4
+ without tripping type errors and that the TypedDict can round-trip through
5
+ JSON serialization unchanged — the SDK treats these as passthrough fields
6
+ between the backend/daemon and the caller.
7
+ """
8
+
9
+ import json
10
+
11
+ import pytest
12
+
13
+ from memory_cloud.types import GrowthStage, Skill
14
+
15
+
16
+ def _base_skill() -> Skill:
17
+ return Skill(
18
+ id="skill-1",
19
+ memory_id="mem-1",
20
+ name="Deploy with Docker Compose",
21
+ summary="Rebuild backend image and restart dependent services.",
22
+ methods=[{"step": 1, "description": "docker compose build backend"}],
23
+ trigger_conditions=[{"pattern": "deploy", "weight": 1.0}],
24
+ tags=["deployment"],
25
+ source_card_ids=["card-a", "card-b"],
26
+ growth_stage="budding",
27
+ usage_count=3,
28
+ decay_score=0.72,
29
+ pinned=False,
30
+ status="active",
31
+ created_at="2026-04-19T00:00:00Z",
32
+ updated_at="2026-04-19T00:00:00Z",
33
+ )
34
+
35
+
36
+ @pytest.mark.unit
37
+ def test_skill_accepts_f059_optional_fields() -> None:
38
+ skill: Skill = _base_skill()
39
+ skill["pitfalls"] = [
40
+ "Do not rebuild postgres — resets scram-sha-256 hash",
41
+ "Avoid `docker compose up` without --env-file .env.prod",
42
+ ]
43
+ skill["verification"] = [
44
+ "curl https://awareness.market/health returns 200",
45
+ "docker compose ps shows all services healthy",
46
+ ]
47
+
48
+ assert skill["pitfalls"][0].startswith("Do not rebuild")
49
+ assert len(skill["verification"]) == 2
50
+
51
+
52
+ @pytest.mark.unit
53
+ def test_skill_round_trips_through_json_with_new_fields() -> None:
54
+ skill: Skill = _base_skill()
55
+ skill["pitfalls"] = ["p1", "p2"]
56
+ skill["verification"] = ["v1"]
57
+
58
+ encoded = json.dumps(skill)
59
+ decoded = json.loads(encoded)
60
+
61
+ assert decoded["pitfalls"] == ["p1", "p2"]
62
+ assert decoded["verification"] == ["v1"]
63
+ assert decoded["growth_stage"] == "budding"
64
+
65
+
66
+ @pytest.mark.unit
67
+ def test_growth_stage_union_accepts_valid_values() -> None:
68
+ # Literal values — these should all be accepted by type-checkers.
69
+ for stage in ("seedling", "budding", "evergreen"):
70
+ value: GrowthStage = stage # type: ignore[assignment]
71
+ skill: Skill = _base_skill()
72
+ skill["growth_stage"] = value
73
+ assert skill["growth_stage"] == stage
74
+
75
+
76
+ @pytest.mark.unit
77
+ def test_skill_without_f059_fields_is_still_valid() -> None:
78
+ """Back-compat: skills created before F-059 must still parse cleanly."""
79
+ skill: Skill = _base_skill()
80
+ # No `pitfalls` / `verification` keys present.
81
+ assert "pitfalls" not in skill
82
+ assert "verification" not in skill
83
+ # Round-trip still works.
84
+ decoded = json.loads(json.dumps(skill))
85
+ assert "pitfalls" not in decoded
86
+ assert "verification" not in decoded