personal memory agent
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3
4import asyncio
5import json
6import logging
7from pathlib import Path
8
9
10def _write_detected_entities(tmp_path, facet: str, day: str, rows: list[dict]) -> None:
11 path = tmp_path / "facets" / facet / "entities" / f"{day}.jsonl"
12 path.parent.mkdir(parents=True, exist_ok=True)
13 path.write_text(
14 "".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows),
15 encoding="utf-8",
16 )
17
18
19def _activity_record(record_id: str = "meeting_090000_300") -> dict:
20 return {
21 "id": record_id,
22 "activity": "meeting",
23 "description": "Team sync",
24 "segments": ["090000_300"],
25 "created_at": 1,
26 }
27
28
29def _context(
30 tmp_path: Path,
31 *,
32 facet: str = "work",
33 day: str = "20260418",
34 record_id: str = "meeting_090000_300",
35 name: str = "conversation",
36) -> dict:
37 return {
38 "facet": facet,
39 "day": day,
40 "name": name,
41 "activity": {"id": record_id},
42 "output_path": str(
43 tmp_path / "facets" / facet / "activities" / day / record_id / "story.json"
44 ),
45 }
46
47
48def _valid_result(**overrides) -> str:
49 payload = {
50 "body": "Aligned on launch work and assigned the follow-up.",
51 "topics": ["launch", "follow-up"],
52 "confidence": 0.82,
53 "commitments": [
54 {
55 "owner": "Mina",
56 "action": "send the revised deck",
57 "counterparty": "Ravi",
58 "when": "Friday morning",
59 "context": "Mina committed to send the deck before the next investor call.",
60 }
61 ],
62 "closures": [
63 {
64 "owner": "Ravi",
65 "action": "intro email",
66 "counterparty": "Mina",
67 "resolution": "sent",
68 "context": "Ravi confirmed the intro email already went out.",
69 }
70 ],
71 "decisions": [
72 {
73 "owner": "Team",
74 "action": "move the launch review to Tuesday",
75 "counterparty": None,
76 "context": "The group aligned on Tuesday after checking calendars.",
77 }
78 ],
79 "relations": [],
80 }
81 payload.update(overrides)
82 return json.dumps(payload)
83
84
85def _load_record(facet: str, day: str):
86 from solstone.think.activities import load_activity_records
87
88 return load_activity_records(facet, day, include_hidden=True)[0]
89
90
91def test_story_hook_parses_and_writes(tmp_path, monkeypatch):
92 from solstone.talent.story import post_process
93 from solstone.think.activities import append_activity_record
94
95 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
96
97 append_activity_record("work", "20260418", _activity_record())
98
99 returned = post_process(
100 _valid_result(body=" Wrapped the launch prep and assigned follow-up. "),
101 _context(tmp_path),
102 )
103
104 record = _load_record("work", "20260418")
105 assert returned == ""
106 assert record["story"] == {
107 "talent": "conversation",
108 "body": "Wrapped the launch prep and assigned follow-up.",
109 "topics": ["launch", "follow-up"],
110 "confidence": 0.82,
111 }
112 assert record["commitments"][0]["owner"] == "Mina"
113 assert record["closures"][0]["resolution"] == "sent"
114 assert record["decisions"][0]["owner"] == "Team"
115 assert record["relations"] == []
116 assert record["edits"][-1]["actor"] == "story"
117 assert record["edits"][-1]["fields"] == [
118 "story",
119 "commitments",
120 "closures",
121 "decisions",
122 "relations",
123 ]
124
125
126def test_story_hook_empty_arrays(tmp_path, monkeypatch):
127 from solstone.talent.story import post_process
128 from solstone.think.activities import append_activity_record
129
130 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
131 append_activity_record("work", "20260418", _activity_record())
132
133 post_process(
134 _valid_result(commitments=[], closures=[], decisions=[]),
135 _context(tmp_path),
136 )
137
138 record = _load_record("work", "20260418")
139 assert (
140 record["story"]["body"] == "Aligned on launch work and assigned the follow-up."
141 )
142 assert record["commitments"] == []
143 assert record["closures"] == []
144 assert record["decisions"] == []
145 assert record["relations"] == []
146
147
148def test_story_hook_bad_resolution_skipped(tmp_path, monkeypatch, caplog):
149 from solstone.talent.story import post_process
150 from solstone.think.activities import append_activity_record
151
152 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
153 append_activity_record("work", "20260418", _activity_record())
154
155 post_process(
156 _valid_result(
157 closures=[
158 {
159 "owner": "Ravi",
160 "action": "intro email",
161 "counterparty": "Mina",
162 "resolution": "sent",
163 "context": "The intro email went out.",
164 },
165 {
166 "owner": "Ravi",
167 "action": "budget request",
168 "counterparty": "Finance",
169 "resolution": "approved",
170 "context": "This resolution is invalid for the schema.",
171 },
172 ]
173 ),
174 _context(tmp_path),
175 )
176
177 record = _load_record("work", "20260418")
178 assert [closure["action"] for closure in record["closures"]] == ["intro email"]
179 assert "invalid resolution 'approved'" in caplog.text
180
181
182def test_story_hook_missing_required_field_skipped(tmp_path, monkeypatch, caplog):
183 from solstone.talent.story import post_process
184 from solstone.think.activities import append_activity_record
185
186 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
187 append_activity_record("work", "20260418", _activity_record())
188
189 post_process(
190 _valid_result(
191 commitments=[
192 {
193 "owner": "Mina",
194 "action": "send the revised deck",
195 "counterparty": "Ravi",
196 "when": "Friday morning",
197 "context": "Valid commitment.",
198 },
199 {
200 "owner": "Mina",
201 "action": "book the room",
202 "when": "tomorrow",
203 "context": "Missing counterparty should skip.",
204 },
205 ],
206 closures=[
207 {
208 "owner": "Ravi",
209 "action": "intro email",
210 "counterparty": "Mina",
211 "resolution": "sent",
212 "context": "Valid closure.",
213 },
214 {
215 "action": "parking pass",
216 "counterparty": "Travel desk",
217 "resolution": "done",
218 "context": "Missing owner should skip.",
219 },
220 ],
221 decisions=[
222 {
223 "owner": "Team",
224 "action": "move the launch review to Tuesday",
225 "counterparty": None,
226 "context": "Valid decision.",
227 },
228 {
229 "owner": "Team",
230 "context": "Missing action should skip.",
231 },
232 ],
233 ),
234 _context(tmp_path),
235 )
236
237 record = _load_record("work", "20260418")
238 assert len(record["commitments"]) == 1
239 assert len(record["closures"]) == 1
240 assert len(record["decisions"]) == 1
241 assert "missing required string field" in caplog.text
242
243
244def test_story_hook_resolves_entities(tmp_path, monkeypatch):
245 from solstone.talent.story import post_process
246 from solstone.think.activities import append_activity_record
247
248 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
249 _write_detected_entities(
250 tmp_path,
251 "work",
252 "20260418",
253 [
254 {"id": "mina_lee", "type": "Person", "name": "Mina Lee", "aka": ["Mina"]},
255 {"id": "ravi_shah", "type": "Person", "name": "Ravi Shah", "aka": ["Ravi"]},
256 ],
257 )
258 append_activity_record("work", "20260418", _activity_record())
259
260 post_process(
261 _valid_result(
262 commitments=[
263 {
264 "owner": "Mina",
265 "action": "send the revised deck",
266 "counterparty": "Ravi",
267 "when": "Friday morning",
268 "context": "Valid commitment.",
269 },
270 {
271 "owner": "Unknown Owner",
272 "action": "draft the note",
273 "counterparty": "Unknown Counterparty",
274 "when": "later",
275 "context": "Unmatched names should stay null.",
276 },
277 ],
278 closures=[
279 {
280 "owner": "Ravi",
281 "action": "intro email",
282 "counterparty": "Mina",
283 "resolution": "sent",
284 "context": "Valid closure.",
285 }
286 ],
287 decisions=[
288 {
289 "owner": "Mina Lee",
290 "action": "move the launch review to Tuesday",
291 "counterparty": "Ravi",
292 "context": "Valid decision.",
293 },
294 {
295 "owner": "Team",
296 "action": "keep the review owner unchanged",
297 "counterparty": None,
298 "context": "Decision without a counterparty.",
299 },
300 ],
301 relations=[
302 {
303 "from": "Mina",
304 "to": "Ravi",
305 "kind": "works-with",
306 "note": "",
307 "quote": "Mina and Ravi will handle the deck.",
308 },
309 {
310 "from": "Mina",
311 "to": "Nobody Visible",
312 "kind": "knows",
313 "note": "Mina mentioned knowing someone outside the tracked entities.",
314 "quote": None,
315 },
316 ],
317 ),
318 _context(tmp_path),
319 )
320
321 record = _load_record("work", "20260418")
322 assert record["commitments"][0]["owner_entity_id"] == "mina_lee"
323 assert record["commitments"][0]["counterparty_entity_id"] == "ravi_shah"
324 assert record["commitments"][1]["owner_entity_id"] is None
325 assert record["commitments"][1]["counterparty_entity_id"] is None
326 assert record["closures"][0]["owner_entity_id"] == "ravi_shah"
327 assert record["closures"][0]["counterparty_entity_id"] == "mina_lee"
328 assert record["decisions"][0]["owner_entity_id"] == "mina_lee"
329 assert record["decisions"][0]["counterparty"] == "Ravi"
330 assert record["decisions"][0]["counterparty_entity_id"] == "ravi_shah"
331 assert record["decisions"][1]["counterparty"] is None
332 assert record["decisions"][1]["counterparty_entity_id"] is None
333 assert record["relations"][0]["from_entity_id"] == "mina_lee"
334 assert record["relations"][0]["to_entity_id"] == "ravi_shah"
335 assert record["relations"][1]["to"] == "Nobody Visible"
336 assert record["relations"][1]["to_entity_id"] is None
337 assert record["relations"][1]["note"] == (
338 "Mina mentioned knowing someone outside the tracked entities."
339 )
340 assert record["relations"][1]["quote"] is None
341 assert record["commitments"][0]["owner"] == "Mina"
342 assert record["closures"][0]["counterparty"] == "Mina"
343
344
345def test_story_hook_ambiguous_entities_keep_names_and_null_ids(tmp_path, monkeypatch):
346 from solstone.talent.story import post_process
347 from solstone.think.activities import append_activity_record
348 from solstone.think.entities import load_ambiguities
349
350 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
351 _write_detected_entities(
352 tmp_path,
353 "work",
354 "20260418",
355 [
356 {"id": "sarah_connor", "type": "Person", "name": "Sarah Connor"},
357 {"id": "sarah_lee", "type": "Person", "name": "Sarah Lee"},
358 ],
359 )
360 append_activity_record("work", "20260418", _activity_record())
361
362 post_process(
363 _valid_result(
364 commitments=[
365 {
366 "owner": "Sarah",
367 "action": "send the revised deck",
368 "counterparty": "Sarah",
369 "when": "Friday morning",
370 "context": "Ambiguous Sarah should not attach.",
371 }
372 ],
373 closures=[],
374 decisions=[],
375 relations=[
376 {
377 "from": "Sarah",
378 "to": "Sarah",
379 "kind": "works-with",
380 "note": "Ambiguous relationship.",
381 }
382 ],
383 ),
384 _context(tmp_path),
385 )
386
387 record = _load_record("work", "20260418")
388 assert record["commitments"][0]["owner"] == "Sarah"
389 assert record["commitments"][0]["owner_entity_id"] is None
390 assert record["commitments"][0]["counterparty_entity_id"] is None
391 assert record["relations"][0]["from"] == "Sarah"
392 assert record["relations"][0]["from_entity_id"] is None
393 assert record["relations"][0]["to_entity_id"] is None
394 rows = load_ambiguities()
395 assert len(rows) == 1
396 assert rows[0]["normalized_query"] == "sarah"
397
398
399def test_story_hook_skips_invalid_relations(tmp_path, monkeypatch, caplog):
400 from solstone.talent.story import post_process
401 from solstone.think.activities import append_activity_record
402
403 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
404 append_activity_record("work", "20260418", _activity_record())
405
406 post_process(
407 _valid_result(
408 relations=[
409 {
410 "from": "Mina",
411 "to": "Ravi",
412 "kind": "works-with",
413 "note": "",
414 "quote": "They are paired on the deck.",
415 },
416 {
417 "from": "Mina",
418 "to": "Ravi",
419 "kind": "other",
420 "note": " ",
421 "quote": "They have a custom relationship.",
422 },
423 {
424 "from": "Mina",
425 "to": "Ravi",
426 "kind": "mentors",
427 "note": "Mina mentors Ravi.",
428 "quote": "Mina mentors Ravi.",
429 },
430 ]
431 ),
432 _context(tmp_path),
433 )
434
435 record = _load_record("work", "20260418")
436 assert [relation["kind"] for relation in record["relations"]] == ["works-with"]
437 assert "other kind requires note" in caplog.text
438 assert "invalid kind 'mentors'" in caplog.text
439
440
441def test_story_hook_idempotent_rerun(tmp_path, monkeypatch):
442 from solstone.talent.story import post_process
443 from solstone.think.activities import append_activity_record
444
445 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
446 append_activity_record("work", "20260418", _activity_record())
447
448 post_process(
449 _valid_result(
450 relations=[
451 {
452 "from": "Mina",
453 "to": "Ravi",
454 "kind": "works-with",
455 "note": "",
456 "quote": "Mina and Ravi owned the first pass.",
457 }
458 ]
459 ),
460 _context(tmp_path),
461 )
462 first = _load_record("work", "20260418")
463 assert len(first["edits"]) == 1
464 assert first["relations"][0]["kind"] == "works-with"
465
466 post_process(
467 _valid_result(
468 body="Second pass with a clearer summary.",
469 topics=["handoff"],
470 commitments=[],
471 closures=[],
472 decisions=[
473 {
474 "owner": "Lead",
475 "action": "ship the patch on Wednesday",
476 "counterparty": None,
477 "context": "The second pass reached a more specific plan.",
478 }
479 ],
480 relations=[
481 {
482 "from": "Lead",
483 "to": "Patch",
484 "kind": "created",
485 "note": "",
486 "quote": None,
487 }
488 ],
489 ),
490 _context(tmp_path),
491 )
492
493 second = _load_record("work", "20260418")
494 assert second["story"] == {
495 "talent": "conversation",
496 "body": "Second pass with a clearer summary.",
497 "topics": ["handoff"],
498 "confidence": 0.82,
499 }
500 assert second["commitments"] == []
501 assert second["closures"] == []
502 assert second["decisions"] == [
503 {
504 "owner": "Lead",
505 "action": "ship the patch on Wednesday",
506 "counterparty": None,
507 "context": "The second pass reached a more specific plan.",
508 "owner_entity_id": None,
509 "counterparty_entity_id": None,
510 }
511 ]
512 assert second["relations"] == [
513 {
514 "from": "Lead",
515 "to": "Patch",
516 "kind": "created",
517 "note": "",
518 "quote": None,
519 "from_entity_id": None,
520 "to_entity_id": None,
521 }
522 ]
523 assert len(second["edits"]) == 2
524 assert second["edits"][-1]["fields"] == [
525 "story",
526 "commitments",
527 "closures",
528 "decisions",
529 "relations",
530 ]
531
532
533def test_story_hook_normalizes_topics(tmp_path, monkeypatch):
534 from solstone.talent.story import post_process
535 from solstone.think.activities import append_activity_record
536
537 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
538 append_activity_record("work", "20260418", _activity_record())
539
540 post_process(
541 _valid_result(
542 topics=["Launch Plan", "follow-up", "LAUNCH plan", " ", "follow-up"]
543 ),
544 _context(tmp_path),
545 )
546
547 record = _load_record("work", "20260418")
548 assert record["story"]["topics"] == ["launch plan", "follow-up"]
549
550
551def test_story_hook_clamps_confidence(tmp_path, monkeypatch, caplog):
552 from solstone.talent.story import post_process
553 from solstone.think.activities import append_activity_record, load_activity_records
554
555 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
556 caplog.set_level(logging.WARNING)
557
558 append_activity_record(
559 "work",
560 "20260418",
561 _activity_record(record_id="meeting_090000_300"),
562 )
563 post_process(_valid_result(confidence=1.4), _context(tmp_path))
564 first = _load_record("work", "20260418")
565 assert first["story"]["confidence"] == 1.0
566 assert "clamped" in caplog.text
567
568 append_activity_record(
569 "work",
570 "20260418",
571 _activity_record(record_id="meeting_100000_300"),
572 )
573 post_process(
574 _valid_result(confidence=-0.2),
575 _context(tmp_path, record_id="meeting_100000_300"),
576 )
577 records = load_activity_records("work", "20260418", include_hidden=True)
578 by_id = {record["id"]: record for record in records}
579 assert by_id["meeting_090000_300"]["story"]["confidence"] == 1.0
580 assert by_id["meeting_100000_300"]["story"]["confidence"] == 0.0
581
582
583def test_story_hook_rejects_nan_confidence(tmp_path, monkeypatch):
584 from solstone.talent.story import post_process
585 from solstone.think.activities import append_activity_record
586
587 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
588 append_activity_record("work", "20260418", _activity_record())
589
590 returned = post_process(
591 _valid_result(confidence=float("nan")),
592 _context(tmp_path),
593 )
594
595 record = _load_record("work", "20260418")
596 assert returned == ""
597 assert "story" not in record
598
599
600def test_story_hook_rejects_non_numeric_confidence(tmp_path, monkeypatch):
601 from solstone.talent.story import post_process
602 from solstone.think.activities import append_activity_record
603
604 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
605 append_activity_record("work", "20260418", _activity_record())
606
607 returned = post_process(
608 _valid_result(confidence="high"),
609 _context(tmp_path),
610 )
611
612 record = _load_record("work", "20260418")
613 assert returned == ""
614 assert "story" not in record
615
616
617def test_story_hook_missing_record_logs_and_returns(tmp_path, monkeypatch, caplog):
618 from solstone.talent.story import post_process
619
620 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
621
622 returned = post_process(_valid_result(), _context(tmp_path))
623
624 assert returned == ""
625 assert "activity record not found" in caplog.text
626
627
628def test_story_hook_no_json_file_written(tmp_path, monkeypatch):
629 from solstone.talent.story import post_process
630 from solstone.think.activities import append_activity_record
631
632 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
633 append_activity_record("work", "20260418", _activity_record())
634
635 output_path = (
636 tmp_path
637 / "facets"
638 / "work"
639 / "activities"
640 / "20260418"
641 / "meeting_090000_300"
642 / "story.json"
643 )
644 returned = post_process(_valid_result(), _context(tmp_path))
645
646 assert returned == ""
647 assert not output_path.exists()
648
649
650def test_generate_seam_story_hook_writes_no_output_file(tmp_path, monkeypatch):
651 from solstone.think.activities import append_activity_record
652 from solstone.think.talents import _execute_generate
653
654 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
655 append_activity_record("work", "20260418", _activity_record())
656
657 output_path = (
658 tmp_path
659 / "facets"
660 / "work"
661 / "activities"
662 / "20260418"
663 / "meeting_090000_300"
664 / "conversation.json"
665 )
666 config = {
667 **_context(tmp_path),
668 "output": "json",
669 "output_path": str(output_path),
670 "hook": {"post": "story"},
671 "prompt": "x",
672 }
673 monkeypatch.setattr(
674 "solstone.think.models.generate_with_result",
675 lambda **kwargs: {"text": _valid_result()},
676 )
677
678 # Exercises the talents.py write-seam gate removed from cortex in 56b5a121.
679 asyncio.run(_execute_generate(config, lambda e: None))
680
681 assert not output_path.exists()
682 record = _load_record("work", "20260418")
683 assert (
684 record["story"]["body"] == "Aligned on launch work and assigned the follow-up."
685 )
686 assert record["edits"][-1]["actor"] == "story"
687
688
689def test_generate_seam_participation_writes_json(tmp_path, monkeypatch):
690 from solstone.think.activities import append_activity_record
691 from solstone.think.talents import _execute_generate
692
693 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
694 append_activity_record("work", "20260418", _activity_record())
695
696 output_path = (
697 tmp_path
698 / "facets"
699 / "work"
700 / "activities"
701 / "20260418"
702 / "meeting_090000_300"
703 / "participation.json"
704 )
705 payload = {
706 "participation": [{"name": "Mina", "role": "mentioned", "source": "text"}]
707 }
708 config = {
709 **_context(tmp_path, name="participation"),
710 "output": "json",
711 "output_path": str(output_path),
712 "hook": {"post": "participation"},
713 "prompt": "x",
714 }
715 monkeypatch.setattr(
716 "solstone.think.models.generate_with_result",
717 lambda **kwargs: {"text": json.dumps(payload)},
718 )
719
720 # Exercises the talents.py write-seam gate removed from cortex in 56b5a121.
721 asyncio.run(_execute_generate(config, lambda e: None))
722
723 assert output_path.exists()
724 assert output_path.stat().st_size > 0
725 assert json.loads(output_path.read_text()) == payload