agentkernel-cli 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

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.
agentkernel/agent.py CHANGED
@@ -67,13 +67,15 @@ class Agent:
67
67
  *,
68
68
  profile: Any | None = None,
69
69
  on_text: Any | None = None,
70
+ images: Any | None = None,
70
71
  ) -> str:
71
72
  """Drive the loop until a final answer or the max-iteration guard.
72
73
 
73
74
  ``profile`` (design §13, Phase 5) is accepted but, in the kernel, only
74
75
  ``tool_filter`` / ``system_prompt`` are honored if trivially present.
75
76
  ``on_text`` (when set) receives streamed text deltas; the loop contract is
76
- otherwise unchanged.
77
+ otherwise unchanged. ``images`` (a list of ``ImageContent``) attaches to
78
+ the user turn; adapters that can't accept images ignore them (§18.6).
77
79
  """
78
80
  session_id = getattr(self.telemetry, "session_id", str(uuid.uuid4()))
79
81
 
@@ -84,7 +86,11 @@ class Agent:
84
86
  self.context.add(message)
85
87
 
86
88
  self.context.add(
87
- Message(role="user", content=self._prepare_user_message(user_input))
89
+ Message(
90
+ role="user",
91
+ content=self._prepare_user_message(user_input),
92
+ images=list(images) if images else [],
93
+ )
88
94
  )
89
95
 
90
96
  # Assemble the cacheable prefix ONCE per run and reuse the same objects
agentkernel/cli.py CHANGED
@@ -20,7 +20,6 @@ import fnmatch
20
20
  import json
21
21
  import sys
22
22
  from collections.abc import Callable
23
- from dataclasses import replace
24
23
  from pathlib import Path
25
24
 
26
25
  from agentkernel.agent import Agent
@@ -35,6 +34,7 @@ from agentkernel.mcp.config import MCPServerConfig
35
34
  from agentkernel.memory import (
36
35
  MemoryStore,
37
36
  NoteStore,
37
+ RecallWeighting,
38
38
  make_memory_store,
39
39
  make_memory_tools,
40
40
  make_note_store,
@@ -43,16 +43,19 @@ from agentkernel.paths import agent_home, global_config_path
43
43
  from agentkernel.profiles import Profile, load_profile
44
44
  from agentkernel.progress import ProgressTelemetry
45
45
  from agentkernel.providers import ProviderError, make_provider
46
+ from agentkernel.roles import provider_for_role, provider_with_model
46
47
  from agentkernel.semantic_memory import SemanticSqliteNoteStore
47
48
  from agentkernel.skills import DirectorySkillStore, make_skill_tool
48
49
  from agentkernel.subagent import make_spawn_tool
49
50
  from agentkernel.telemetry import JsonlTelemetry, NullTelemetry
50
51
  from agentkernel.tools import ToolRegistry
51
52
  from agentkernel.tools.builtin import default_tools
53
+ from agentkernel.types import ImageContent
52
54
 
53
55
  _BANNER = (
54
56
  "agentkernel REPL - type your message and press enter. Commands: /exit, "
55
- "/clear, /system, /profile, /skills, /skill, /tools, /trace, /cost, /memory, /improve."
57
+ "/clear, /image, /system, /profile, /skills, /skill, /tools, /trace, /cost, "
58
+ "/memory, /improve."
56
59
  )
57
60
  _PROMPT = "> "
58
61
  _EXIT_WORDS = {"exit", "quit", ":q"}
@@ -64,6 +67,12 @@ def _make_configured_note_store(config: Config) -> NoteStore:
64
67
  Shared by build_runtime (memory tools) and run_memory (curation), so the
65
68
  notebook backend is selected identically in both.
66
69
  """
70
+ weighting = RecallWeighting(
71
+ recency_weight=config.memory_recency_weight,
72
+ importance_weight=config.memory_importance_weight,
73
+ half_life_days=config.memory_half_life_days,
74
+ )
75
+ scope = _resolve_memory_scope(config)
67
76
  if config.semantic_search:
68
77
  try:
69
78
  emb_provider = OpenAIEmbeddingProvider.from_config(config)
@@ -74,10 +83,26 @@ def _make_configured_note_store(config: Config) -> NoteStore:
74
83
  notes_path,
75
84
  embedding_provider=emb_provider,
76
85
  lsh_bits=config.semantic_search_lsh_bits,
86
+ weighting=weighting,
87
+ scope=scope,
77
88
  )
78
89
  except EmbeddingError as exc:
79
90
  print(f"Warning: semantic search disabled: {exc}", file=sys.stderr)
80
- return make_note_store(config.memory_notes_path)
91
+ return make_note_store(config.memory_notes_path, weighting=weighting, scope=scope)
92
+
93
+
94
+ def _resolve_memory_scope(config: Config) -> str | None:
95
+ """Resolve ``config.memory_scope`` to the active namespace (or ``None`` = off).
96
+
97
+ ``"auto"`` derives a stable name from the project directory; an empty value
98
+ disables scoping; any other string is used literally.
99
+ """
100
+ raw = (config.memory_scope or "").strip()
101
+ if not raw:
102
+ return None
103
+ if raw.lower() == "auto":
104
+ return Path(config.working_dir or ".").resolve().name or None
105
+ return raw
81
106
 
82
107
 
83
108
  def build_runtime(
@@ -189,9 +214,7 @@ def build_runtime(
189
214
  budget_for_context = provider.context_window - config.output_reserve
190
215
  summarizer = None
191
216
  if config.summarizer_model:
192
- summarizer = ModelSummarizer(
193
- make_provider(replace(config, model=config.summarizer_model))
194
- )
217
+ summarizer = ModelSummarizer(provider_for_role(config, "summarize"))
195
218
  context = ContextManager(
196
219
  budget=budget_for_context,
197
220
  keep_recent_turns=config.keep_recent_turns,
@@ -202,8 +225,7 @@ def build_runtime(
202
225
  if config.approval_policy == "smart":
203
226
  from agentkernel.approval.risk import RiskJudge
204
227
 
205
- judge_model = config.approval_judge_model or config.summarizer_model or config.model
206
- risk_judge = RiskJudge(make_provider(replace(config, model=judge_model)))
228
+ risk_judge = RiskJudge(provider_for_role(config, "classify"))
207
229
  approver = CliApprover(
208
230
  config.approval_policy,
209
231
  allowlist=config.approval_allowlist,
@@ -265,6 +287,7 @@ def _handle_slash(
265
287
  profile: Profile,
266
288
  config: Config,
267
289
  output_fn: Callable[[str], None],
290
+ staged_images: list[ImageContent] | None = None,
268
291
  ) -> bool:
269
292
  """Process a REPL slash command. Returns True if the line was handled."""
270
293
  parts = line.split(None, 1)
@@ -279,6 +302,33 @@ def _handle_slash(
279
302
  output_fn("[context cleared]")
280
303
  return True
281
304
 
305
+ if cmd == "image":
306
+ target = arg.strip()
307
+ if not target or target == "list":
308
+ count = len(staged_images or [])
309
+ output_fn(
310
+ f"[{count} image(s) staged for the next message]"
311
+ if count
312
+ else "[no images staged] usage: /image <path-or-url> | /image clear"
313
+ )
314
+ return True
315
+ if target == "clear":
316
+ if staged_images is not None:
317
+ staged_images.clear()
318
+ output_fn("[staged images cleared]")
319
+ return True
320
+ try:
321
+ loaded = _load_images([target]) or []
322
+ except (OSError, ValueError) as exc:
323
+ output_fn(f"[image error] {exc}")
324
+ return True
325
+ if staged_images is not None:
326
+ staged_images.extend(loaded)
327
+ supported = getattr(agent._provider_for(profile), "supports_images", False)
328
+ warn = "" if supported else f" (note: provider {config.provider!r} can't accept images)"
329
+ output_fn(f"[staged image: {target}]{warn}")
330
+ return True
331
+
282
332
  if cmd == "system":
283
333
  if not arg:
284
334
  output_fn("[system prompt cleared]")
@@ -435,6 +485,7 @@ def repl(
435
485
  streaming = stream_fn is not None and getattr(cfg, "stream", True)
436
486
  output_fn(_BANNER)
437
487
  profile = Profile(name="default")
488
+ staged_images: list[ImageContent] = [] # attached to the next message, then cleared
438
489
  while True:
439
490
  try:
440
491
  line = input_fn(_PROMPT).strip()
@@ -446,7 +497,7 @@ def repl(
446
497
  if line.lower() in _EXIT_WORDS:
447
498
  break
448
499
  if line.startswith("/"):
449
- if not _handle_slash(line, agent, profile, cfg, output_fn):
500
+ if not _handle_slash(line, agent, profile, cfg, output_fn, staged_images):
450
501
  break
451
502
  continue
452
503
  streamed = {"any": False}
@@ -455,13 +506,18 @@ def repl(
455
506
  _s["any"] = True
456
507
  stream_fn(text) # type: ignore[misc]
457
508
 
509
+ images = list(staged_images) if staged_images else None
458
510
  try:
459
511
  answer = agent.run(
460
- line, profile=profile, on_text=on_text if streaming else None
512
+ line,
513
+ profile=profile,
514
+ on_text=on_text if streaming else None,
515
+ images=images,
461
516
  )
462
517
  except ProviderError as exc:
463
518
  output_fn(f"[provider error] {exc}")
464
519
  continue
520
+ staged_images.clear() # consumed by the message just sent
465
521
  if streaming and streamed["any"]:
466
522
  stream_fn("\n") # type: ignore[misc]
467
523
  else:
@@ -469,6 +525,19 @@ def repl(
469
525
  return 0
470
526
 
471
527
 
528
+ def _load_images(specs: list[str] | None) -> list[ImageContent] | None:
529
+ """Build ImageContent from --image specs (http(s) URL or local file path)."""
530
+ if not specs:
531
+ return None
532
+ images: list[ImageContent] = []
533
+ for spec in specs:
534
+ if spec.startswith(("http://", "https://")):
535
+ images.append(ImageContent.from_url(spec))
536
+ else:
537
+ images.append(ImageContent.from_path(spec))
538
+ return images
539
+
540
+
472
541
  def run_once(
473
542
  agent: Agent,
474
543
  prompt: str,
@@ -477,19 +546,29 @@ def run_once(
477
546
  output_fn: Callable[[str], None] = print,
478
547
  stream_fn: Callable[[str], None] | None = None,
479
548
  config: Config | None = None,
549
+ images: list[ImageContent] | None = None,
480
550
  ) -> int:
481
551
  """Execute a single non-interactive turn and print (or stream) the answer."""
482
552
  cfg = config or agent.config
483
553
  streaming = stream_fn is not None and getattr(cfg, "stream", True)
484
554
  streamed = {"any": False}
485
555
 
556
+ if images and not getattr(agent._provider_for(profile), "supports_images", False):
557
+ output_fn(
558
+ f"[warning] provider {cfg.provider!r} (model {cfg.model!r}) does not "
559
+ "accept images; sending text only"
560
+ )
561
+
486
562
  def on_text(text: str) -> None:
487
563
  streamed["any"] = True
488
564
  stream_fn(text) # type: ignore[misc]
489
565
 
490
566
  try:
491
567
  answer = agent.run(
492
- prompt, profile=profile, on_text=on_text if streaming else None
568
+ prompt,
569
+ profile=profile,
570
+ on_text=on_text if streaming else None,
571
+ images=images,
493
572
  )
494
573
  except ProviderError as exc:
495
574
  output_fn(f"[provider error] {exc}")
@@ -564,6 +643,106 @@ def run_new(
564
643
  return 0
565
644
 
566
645
 
646
+ def run_completion(
647
+ shell: str, commands: list[str], *, output_fn: Callable[[str], None] = print
648
+ ) -> int:
649
+ """Print a shell completion script for ``agentkernel`` (§18.7).
650
+
651
+ Completes the subcommand at the first position and defers to file completion
652
+ afterwards. The command list is passed in from the live parser so the script
653
+ never drifts from the actual subcommands.
654
+ """
655
+ words = " ".join(commands)
656
+ if shell == "bash":
657
+ script = f"""\
658
+ # agentkernel bash completion. Install: source <(agentkernel completion bash)
659
+ _agentkernel_completion() {{
660
+ local cur="${{COMP_WORDS[COMP_CWORD]}}"
661
+ if [ "$COMP_CWORD" -eq 1 ]; then
662
+ COMPREPLY=( $(compgen -W "{words}" -- "$cur") )
663
+ else
664
+ COMPREPLY=( $(compgen -f -- "$cur") )
665
+ fi
666
+ }}
667
+ complete -F _agentkernel_completion agentkernel
668
+ """
669
+ elif shell == "zsh":
670
+ script = f"""\
671
+ #compdef agentkernel
672
+ # agentkernel zsh completion. Install: agentkernel completion zsh > ~/.zsh/_agentkernel
673
+ _agentkernel() {{
674
+ local -a commands
675
+ commands=({words})
676
+ if (( CURRENT == 2 )); then
677
+ compadd -- $commands
678
+ else
679
+ _files
680
+ fi
681
+ }}
682
+ _agentkernel "$@"
683
+ """
684
+ elif shell == "fish":
685
+ lines = [
686
+ "# agentkernel fish completion. Install: "
687
+ "agentkernel completion fish > ~/.config/fish/completions/agentkernel.fish",
688
+ f'complete -c agentkernel -n "__fish_use_subcommand" -a "{words}"',
689
+ ]
690
+ script = "\n".join(lines) + "\n"
691
+ else:
692
+ output_fn(f"[unsupported shell: {shell}] choose bash, zsh, or fish")
693
+ return 1
694
+ output_fn(script)
695
+ return 0
696
+
697
+
698
+ def run_skill(
699
+ config: Config,
700
+ action: str,
701
+ target: str | None,
702
+ *,
703
+ out_path: str | None = None,
704
+ force: bool = False,
705
+ output_fn: Callable[[str], None] = print,
706
+ ) -> int:
707
+ """Manage shareable skill bundles: ``list`` / ``pack`` / ``install`` (§18.8)."""
708
+ from agentkernel.skills import SkillLibrary, install_skill, pack_skill
709
+
710
+ skills_dir = config.skills_dir
711
+ if action == "list":
712
+ library = SkillLibrary(skills_dir)
713
+ described = library.describe()
714
+ if not described:
715
+ output_fn(f"[no skills in {skills_dir}]")
716
+ return 0
717
+ for name, desc in described:
718
+ output_fn(f"- {name}: {desc}")
719
+ return 0
720
+ if action == "pack":
721
+ if not target:
722
+ output_fn("[pack] a skill name is required")
723
+ return 1
724
+ try:
725
+ archive = pack_skill(skills_dir, target, out_path=out_path)
726
+ except FileNotFoundError as exc:
727
+ output_fn(f"[pack] {exc}")
728
+ return 1
729
+ output_fn(f"[packed {target}: {archive}]")
730
+ return 0
731
+ if action == "install":
732
+ if not target:
733
+ output_fn("[install] an archive path is required")
734
+ return 1
735
+ try:
736
+ name = install_skill(target, skills_dir, force=force)
737
+ except (FileExistsError, ValueError, OSError) as exc:
738
+ output_fn(f"[install] {exc}")
739
+ return 1
740
+ output_fn(f"[installed skill {name!r} into {skills_dir}]")
741
+ return 0
742
+ output_fn(f"[unknown skill action: {action}] choose list, pack, or install")
743
+ return 1
744
+
745
+
567
746
  _PROJECT_CONFIG_TEMPLATE = """\
568
747
  # agentkernel project config. Global defaults live in ~/.agentkernel/config.toml
569
748
  # (or $AGENTKERNEL_HOME); keys here override them for this project.
@@ -882,8 +1061,7 @@ def run_memory(
882
1061
  from agentkernel.curation import MemoryCurator
883
1062
 
884
1063
  notes = _make_configured_note_store(config)
885
- curator_model = config.memory_curator_model or config.summarizer_model or config.model
886
- provider = make_provider(replace(config, model=curator_model))
1064
+ provider = provider_for_role(config, "curate")
887
1065
  curator = MemoryCurator(notes, provider)
888
1066
 
889
1067
  if action == "consolidate":
@@ -1005,11 +1183,11 @@ def run_eval(
1005
1183
  context_source=base_agent.context_source,
1006
1184
  )
1007
1185
 
1186
+ # An explicit --judge-model (or config.judge_model) overrides; otherwise reuse
1187
+ # the agent's own provider rather than spinning up a duplicate.
1008
1188
  judge_model = judge_model or config.judge_model
1009
1189
  judge = (
1010
- make_provider(replace(config, model=judge_model))
1011
- if judge_model
1012
- else base_agent.provider
1190
+ provider_with_model(config, judge_model) if judge_model else base_agent.provider
1013
1191
  )
1014
1192
  evaluator = Evaluator(
1015
1193
  agent_factory, judge,
@@ -1196,6 +1374,12 @@ def main(argv: list[str] | None = None) -> int:
1196
1374
  action="store_true",
1197
1375
  help="run detached in the background; output goes to a file",
1198
1376
  )
1377
+ run_parser.add_argument(
1378
+ "--image",
1379
+ action="append",
1380
+ metavar="PATH_OR_URL",
1381
+ help="attach an image to the prompt (repeatable); a local path or http(s) URL",
1382
+ )
1199
1383
  improve_parser = subparsers.add_parser(
1200
1384
  "improve", help="reflect on a session trace and write an improvement note"
1201
1385
  )
@@ -1291,6 +1475,25 @@ def main(argv: list[str] | None = None) -> int:
1291
1475
  "--force", action="store_true", help="overwrite if the target already exists"
1292
1476
  )
1293
1477
 
1478
+ skill_parser = subparsers.add_parser(
1479
+ "skill", help="package and install shareable skill bundles"
1480
+ )
1481
+ skill_parser.add_argument(
1482
+ "skill_action", choices=("list", "pack", "install"), help="what to do"
1483
+ )
1484
+ skill_parser.add_argument(
1485
+ "target", nargs="?", help="skill name (pack) or bundle path (install)"
1486
+ )
1487
+ skill_parser.add_argument("--out", help="output path for `pack` (.skill.zip)")
1488
+ skill_parser.add_argument(
1489
+ "--force", action="store_true", help="overwrite on `install`"
1490
+ )
1491
+
1492
+ completion_parser = subparsers.add_parser(
1493
+ "completion", help="print a shell completion script (bash/zsh/fish)"
1494
+ )
1495
+ completion_parser.add_argument("shell", choices=("bash", "zsh", "fish"))
1496
+
1294
1497
  args = parser.parse_args(argv)
1295
1498
  command = getattr(args, "command", None) or "repl"
1296
1499
 
@@ -1306,6 +1509,8 @@ def main(argv: list[str] | None = None) -> int:
1306
1509
  )
1307
1510
  if command == "new":
1308
1511
  return run_new(args.kind, args.name, force=args.force)
1512
+ if command == "completion":
1513
+ return run_completion(args.shell, sorted(subparsers.choices))
1309
1514
 
1310
1515
  config, project_config_path = resolve_config(args.config, cwd=args.cwd or ".")
1311
1516
  # The concrete config path to hand to subprocesses / MCP discovery.
@@ -1334,6 +1539,14 @@ def main(argv: list[str] | None = None) -> int:
1334
1539
  return run_sessions(config, args.action, getattr(args, "session_id", None))
1335
1540
  if command == "memory":
1336
1541
  return run_memory(config, args.action, session=getattr(args, "session", None))
1542
+ if command == "skill":
1543
+ return run_skill(
1544
+ config,
1545
+ args.skill_action,
1546
+ args.target,
1547
+ out_path=getattr(args, "out", None),
1548
+ force=getattr(args, "force", False),
1549
+ )
1337
1550
  if command == "cron":
1338
1551
  return run_cron(config, args.action, args.rest)
1339
1552
  if command == "kanban":
@@ -1467,8 +1680,14 @@ def main(argv: list[str] | None = None) -> int:
1467
1680
  prompt = args.prompt
1468
1681
  if args.file:
1469
1682
  prompt = _read_prompt_file(args.file)
1683
+ images = _load_images(getattr(args, "image", None))
1470
1684
  return run_once(
1471
- agent, prompt or "", profile=profile, stream_fn=stream_fn, config=config
1685
+ agent,
1686
+ prompt or "",
1687
+ profile=profile,
1688
+ stream_fn=stream_fn,
1689
+ config=config,
1690
+ images=images,
1472
1691
  )
1473
1692
  return repl(agent, config=config, stream_fn=stream_fn)
1474
1693
  finally:
agentkernel/config.py CHANGED
@@ -20,9 +20,10 @@ DEFAULT_CONFIG_FILE = "agentkernel.toml"
20
20
 
21
21
  @dataclass
22
22
  class Config:
23
- provider: str = "anthropic" # "anthropic" | "openai" | "local"
23
+ provider: str = "anthropic" # anthropic|openai|local|openrouter|deepseek|gemini
24
24
  model: str = "claude-sonnet-4-6"
25
25
  base_url: str | None = None # for local/OpenAI-compatible endpoints
26
+ local_supports_images: bool = False # local endpoint accepts image_url parts (§18.6)
26
27
  max_output_tokens: int = 4096
27
28
  output_reserve: int = 8192 # budget headroom for the reply
28
29
  max_iterations: int = 25
@@ -62,6 +63,10 @@ class Config:
62
63
  memory_auto_context_limit: int = 3 # max notes per auto-recall
63
64
  memory_store_budget: int | None = None # max tokens to persist per session
64
65
  memory_curator_model: str | None = None # cheap model for memory extract/consolidate
66
+ memory_scope: str | None = None # recall namespace: "auto" (project dir) | name | None=off
67
+ memory_recency_weight: float = 0.0 # boost recall of recently-created notes
68
+ memory_importance_weight: float = 0.0 # boost recall of often-recalled notes
69
+ memory_half_life_days: float = 30.0 # age (days) for a note's recency score to halve
65
70
  semantic_search: bool = False # rank note recall with dense embeddings
66
71
  embedding_model: str = "text-embedding-3-small"
67
72
  embedding_dimensions: int | None = None # optional truncation (OpenAI only)
@@ -24,6 +24,11 @@ from agentkernel.types import Message
24
24
  if TYPE_CHECKING:
25
25
  from agentkernel.providers import Provider
26
26
 
27
+ # Flat token charge per attached image (design §18.6). A rough upper-mid estimate
28
+ # for a typical tiled image; cheaper than measuring the base64 payload and avoids
29
+ # wildly over-counting an inline data URI.
30
+ IMAGE_TOKEN_ESTIMATE = 1000
31
+
27
32
  # A summarizer turns a list of (old) messages into one summary string. The
28
33
  # default is the deterministic structural fallback below; a model-based
29
34
  # summarizer can be injected here (design §9.2).
@@ -40,13 +45,19 @@ class CompactionEvent:
40
45
 
41
46
 
42
47
  def estimate_tokens(message: Message) -> int:
43
- """Conservative chars/4 estimate for one message (design §9.1)."""
48
+ """Conservative chars/4 estimate for one message (design §9.1).
49
+
50
+ Images are not text, so they are charged a flat per-image estimate rather
51
+ than by their (large) base64 length — close enough to keep the budget honest
52
+ without over-counting an inline data URI.
53
+ """
44
54
  chars = len(message.content or "")
45
55
  for tc in message.tool_calls:
46
56
  chars += len(tc.name) + len(json.dumps(tc.arguments))
47
57
  for r in message.tool_results:
48
58
  chars += len(r.content or "")
49
- return max(1, chars // CHARS_PER_TOKEN)
59
+ image_tokens = len(message.images) * IMAGE_TOKEN_ESTIMATE
60
+ return max(1, chars // CHARS_PER_TOKEN + image_tokens)
50
61
 
51
62
 
52
63
  def structural_summary(messages: list[Message]) -> str:
agentkernel/curation.py CHANGED
@@ -136,26 +136,59 @@ class MemoryCurator:
136
136
  existing = self._notes.all()
137
137
  if len(existing) < 2:
138
138
  return ConsolidationResult(len(existing), len(existing), existing)
139
+ # Consolidate within each namespace so distinct scopes are never merged
140
+ # together and every rebuilt note keeps its original scope. Without this,
141
+ # rebuilding via add() would re-stamp every note with the store's active
142
+ # scope, collapsing global and other-project notes into one namespace.
143
+ groups: dict[str, list[MemoryNote]] = {}
144
+ for note in existing:
145
+ groups.setdefault(note.scope, []).append(note)
146
+
147
+ new_notes: list[MemoryNote] = []
148
+ changed = False
149
+ for scope, group in groups.items():
150
+ rebuilt = self._consolidate_group(scope, group)
151
+ if rebuilt is None: # no-op for this group (too small or unparseable)
152
+ new_notes.extend(group)
153
+ else:
154
+ new_notes.extend(rebuilt)
155
+ changed = True
156
+ if not changed:
157
+ return ConsolidationResult(len(existing), len(existing), existing)
158
+ return ConsolidationResult(len(existing), len(new_notes), new_notes)
159
+
160
+ def _consolidate_group(
161
+ self, scope: str, group: list[MemoryNote]
162
+ ) -> list[MemoryNote] | None:
163
+ """Consolidate one namespace's notes, preserving ``scope``.
164
+
165
+ Returns the rebuilt notes, or ``None`` to signal a no-op (fewer than two
166
+ notes, or an empty/unparseable model reply) so the caller keeps the
167
+ originals untouched.
168
+ """
169
+ if len(group) < 2:
170
+ return None
139
171
  listing = "\n".join(
140
172
  f"{n.note_id}. {n.text}"
141
173
  + (f" [tags: {', '.join(n.tags)}]" if n.tags else "")
142
- for n in existing
174
+ for n in group
143
175
  )
144
176
  cleaned = [
145
177
  c for c in self._ask(_CONSOLIDATE_SYSTEM, f"Current memory notes:\n{listing}")
146
178
  if str(c.get("text", "")).strip()
147
179
  ]
148
180
  if not cleaned:
149
- return ConsolidationResult(len(existing), len(existing), existing) # no-op
150
- # Rebuild from the consolidated set using the store's public API so all
151
- # backends (JSONL / SQLite / semantic) stay consistent.
152
- for note in existing:
181
+ return None
182
+ # Rebuild this group using the store's public API (so all backends stay
183
+ # consistent), re-stamping each note with the group's original scope.
184
+ for note in group:
153
185
  self._notes.forget(note_id=note.note_id)
154
- new_notes = [
155
- self._notes.add(str(c["text"]).strip(), tags=c.get("tags") or [])
186
+ return [
187
+ self._notes.add(
188
+ str(c["text"]).strip(), tags=c.get("tags") or [], scope=scope
189
+ )
156
190
  for c in cleaned
157
191
  ]
158
- return ConsolidationResult(len(existing), len(new_notes), new_notes)
159
192
 
160
193
  # --- internals ---------------------------------------------------------
161
194