memctl 0.3.0__tar.gz → 0.4.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 (46) hide show
  1. {memctl-0.3.0 → memctl-0.4.0}/PKG-INFO +47 -5
  2. {memctl-0.3.0 → memctl-0.4.0}/README.md +46 -4
  3. {memctl-0.3.0 → memctl-0.4.0}/memctl/__init__.py +1 -1
  4. memctl-0.4.0/memctl/chat.py +414 -0
  5. {memctl-0.3.0 → memctl-0.4.0}/memctl/cli.py +76 -0
  6. {memctl-0.3.0 → memctl-0.4.0}/memctl.egg-info/PKG-INFO +47 -5
  7. {memctl-0.3.0 → memctl-0.4.0}/memctl.egg-info/SOURCES.txt +2 -0
  8. {memctl-0.3.0 → memctl-0.4.0}/pyproject.toml +1 -1
  9. memctl-0.4.0/tests/test_chat.py +317 -0
  10. {memctl-0.3.0 → memctl-0.4.0}/tests/test_cli.py +37 -1
  11. {memctl-0.3.0 → memctl-0.4.0}/LICENSE +0 -0
  12. {memctl-0.3.0 → memctl-0.4.0}/memctl/config.py +0 -0
  13. {memctl-0.3.0 → memctl-0.4.0}/memctl/consolidate.py +0 -0
  14. {memctl-0.3.0 → memctl-0.4.0}/memctl/extract.py +0 -0
  15. {memctl-0.3.0 → memctl-0.4.0}/memctl/ingest.py +0 -0
  16. {memctl-0.3.0 → memctl-0.4.0}/memctl/inspect.py +0 -0
  17. {memctl-0.3.0 → memctl-0.4.0}/memctl/loop.py +0 -0
  18. {memctl-0.3.0 → memctl-0.4.0}/memctl/mcp/__init__.py +0 -0
  19. {memctl-0.3.0 → memctl-0.4.0}/memctl/mcp/formatting.py +0 -0
  20. {memctl-0.3.0 → memctl-0.4.0}/memctl/mcp/server.py +0 -0
  21. {memctl-0.3.0 → memctl-0.4.0}/memctl/mcp/tools.py +0 -0
  22. {memctl-0.3.0 → memctl-0.4.0}/memctl/mount.py +0 -0
  23. {memctl-0.3.0 → memctl-0.4.0}/memctl/policy.py +0 -0
  24. {memctl-0.3.0 → memctl-0.4.0}/memctl/proposer.py +0 -0
  25. {memctl-0.3.0 → memctl-0.4.0}/memctl/similarity.py +0 -0
  26. {memctl-0.3.0 → memctl-0.4.0}/memctl/store.py +0 -0
  27. {memctl-0.3.0 → memctl-0.4.0}/memctl/sync.py +0 -0
  28. {memctl-0.3.0 → memctl-0.4.0}/memctl/types.py +0 -0
  29. {memctl-0.3.0 → memctl-0.4.0}/memctl.egg-info/dependency_links.txt +0 -0
  30. {memctl-0.3.0 → memctl-0.4.0}/memctl.egg-info/entry_points.txt +0 -0
  31. {memctl-0.3.0 → memctl-0.4.0}/memctl.egg-info/requires.txt +0 -0
  32. {memctl-0.3.0 → memctl-0.4.0}/memctl.egg-info/top_level.txt +0 -0
  33. {memctl-0.3.0 → memctl-0.4.0}/setup.cfg +0 -0
  34. {memctl-0.3.0 → memctl-0.4.0}/tests/test_contracts.py +0 -0
  35. {memctl-0.3.0 → memctl-0.4.0}/tests/test_extract.py +0 -0
  36. {memctl-0.3.0 → memctl-0.4.0}/tests/test_forward_compat.py +0 -0
  37. {memctl-0.3.0 → memctl-0.4.0}/tests/test_ingest.py +0 -0
  38. {memctl-0.3.0 → memctl-0.4.0}/tests/test_inspect.py +0 -0
  39. {memctl-0.3.0 → memctl-0.4.0}/tests/test_loop.py +0 -0
  40. {memctl-0.3.0 → memctl-0.4.0}/tests/test_mount.py +0 -0
  41. {memctl-0.3.0 → memctl-0.4.0}/tests/test_pipe_compose.py +0 -0
  42. {memctl-0.3.0 → memctl-0.4.0}/tests/test_policy.py +0 -0
  43. {memctl-0.3.0 → memctl-0.4.0}/tests/test_similarity.py +0 -0
  44. {memctl-0.3.0 → memctl-0.4.0}/tests/test_store.py +0 -0
  45. {memctl-0.3.0 → memctl-0.4.0}/tests/test_sync.py +0 -0
  46. {memctl-0.3.0 → memctl-0.4.0}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memctl
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: A Unix-native memory control plane for LLM orchestration
5
5
  Author-email: Olivier Vitrac <olivier.vitrac@adservio.fr>
6
6
  License: MIT
@@ -165,7 +165,19 @@ memctl inspect docs/ --no-sync
165
165
 
166
166
  `inspect` auto-mounts the folder if needed, checks staleness, syncs only if stale, and produces a structural summary. All implicit actions are announced on stderr.
167
167
 
168
- ### 6. Manage
168
+ ### 6. Chat with memory-backed context
169
+
170
+ ```bash
171
+ # Interactive chat with any LLM
172
+ memctl chat --llm "claude -p" --session
173
+
174
+ # With pre-ingested files and answer storage
175
+ memctl chat --llm "ollama run granite3.1:2b" --source docs/ --store --session
176
+ ```
177
+
178
+ Each question recalls from the memory store, sends context + question to the LLM, and displays the answer. `--session` keeps a sliding window of recent Q&A pairs. `--store` persists answers as STM items.
179
+
180
+ ### 7. Manage
169
181
 
170
182
  ```bash
171
183
  memctl show MEM-abc123def456 # Show item details
@@ -198,6 +210,7 @@ memctl <command> [options]
198
210
  | `mount PATH` | Register a folder as a structured source |
199
211
  | `sync [PATH]` | Delta-sync mounted folders into the store |
200
212
  | `inspect [PATH]` | Structural inspection with auto-mount and auto-sync |
213
+ | `chat --llm CMD` | Interactive memory-backed chat REPL |
201
214
  | `serve` | Start MCP server (requires `memctl[mcp]`) |
202
215
 
203
216
  ### Global Flags
@@ -355,6 +368,31 @@ Output includes file/chunk/size totals, per-folder breakdown, per-extension dist
355
368
 
356
369
  All implicit actions (mount, sync) are announced on stderr. `--quiet` suppresses them.
357
370
 
371
+ #### `memctl chat`
372
+
373
+ ```bash
374
+ memctl chat --llm CMD [--session] [--store] [--protocol passive|json|regex]
375
+ [--max-calls N] [--budget N] [--source FILE ...] [--quiet]
376
+ ```
377
+
378
+ Interactive memory-backed chat REPL. Each turn: FTS5 recall from the memory store, send context + question to the LLM, display the answer.
379
+
380
+ **Stateless by default.** Each question sees only the memory store — no hidden conversation state.
381
+
382
+ | Flag | Default | Description |
383
+ |------|---------|-------------|
384
+ | `--llm CMD` | *(required)* | LLM command (e.g. `"claude -p"`, `"ollama run granite3.1:2b"`) |
385
+ | `--protocol` | `passive` | LLM output protocol. `passive` = single-pass; `json` = iterative refinement |
386
+ | `--max-calls` | `1` | Max loop iterations per turn |
387
+ | `--session` | off | Enable in-memory session context (sliding window of recent Q&A) |
388
+ | `--history-turns` | `5` | Session window size (turns) |
389
+ | `--session-budget` | `4000` | Session block character limit |
390
+ | `--store` | off | Persist each answer as STM item |
391
+ | `--source FILE...` | *(none)* | Pre-ingest files before starting |
392
+ | `--tags` | `chat` | Tags for stored items (comma-separated) |
393
+
394
+ **stdout purity:** answers go to stdout only. Prompt, banner, and hints go to stderr.
395
+
358
396
  ---
359
397
 
360
398
  ## Environment Variables
@@ -421,6 +459,9 @@ memctl inspect src/ --json | jq '.extensions'
421
459
 
422
460
  # Inspect without syncing (use cached state)
423
461
  memctl inspect docs/ --no-sync --json
462
+
463
+ # Interactive chat with pre-ingested docs
464
+ memctl chat --llm "claude -p" --source docs/ --session --store
424
465
  ```
425
466
 
426
467
  ---
@@ -485,7 +526,8 @@ memctl/
485
526
  ├── mount.py Folder mount registration and management
486
527
  ├── sync.py Delta sync with 3-tier change detection
487
528
  ├── inspect.py Structural inspection and orchestration
488
- ├── cli.py 12 CLI commands
529
+ ├── chat.py Interactive memory-backed chat REPL
530
+ ├── cli.py 13 CLI commands
489
531
  ├── consolidate.py Deterministic merge (Jaccard clustering, no LLM)
490
532
  ├── proposer.py LLM output parsing (delimiter + regex)
491
533
  └── mcp/
@@ -494,7 +536,7 @@ memctl/
494
536
  └── server.py FastMCP server entry point
495
537
  ```
496
538
 
497
- 19 source files. ~7,300 lines. Zero compiled dependencies for core.
539
+ 20 source files. ~7,700 lines. Zero compiled dependencies for core.
498
540
 
499
541
  ### Memory Tiers
500
542
 
@@ -653,7 +695,7 @@ pip install memctl[dev]
653
695
  pytest tests/ -v
654
696
  ```
655
697
 
656
- 479 tests across 14 test files covering types, store, policy, ingest, text extraction, similarity, loop controller, mount, sync, inspect, forward compatibility, contracts, CLI (subprocess), and pipe composition.
698
+ 492 tests across 15 test files covering types, store, policy, ingest, text extraction, similarity, loop controller, mount, sync, inspect, chat, forward compatibility, contracts, CLI (subprocess), and pipe composition.
657
699
 
658
700
  ---
659
701
 
@@ -122,7 +122,19 @@ memctl inspect docs/ --no-sync
122
122
 
123
123
  `inspect` auto-mounts the folder if needed, checks staleness, syncs only if stale, and produces a structural summary. All implicit actions are announced on stderr.
124
124
 
125
- ### 6. Manage
125
+ ### 6. Chat with memory-backed context
126
+
127
+ ```bash
128
+ # Interactive chat with any LLM
129
+ memctl chat --llm "claude -p" --session
130
+
131
+ # With pre-ingested files and answer storage
132
+ memctl chat --llm "ollama run granite3.1:2b" --source docs/ --store --session
133
+ ```
134
+
135
+ Each question recalls from the memory store, sends context + question to the LLM, and displays the answer. `--session` keeps a sliding window of recent Q&A pairs. `--store` persists answers as STM items.
136
+
137
+ ### 7. Manage
126
138
 
127
139
  ```bash
128
140
  memctl show MEM-abc123def456 # Show item details
@@ -155,6 +167,7 @@ memctl <command> [options]
155
167
  | `mount PATH` | Register a folder as a structured source |
156
168
  | `sync [PATH]` | Delta-sync mounted folders into the store |
157
169
  | `inspect [PATH]` | Structural inspection with auto-mount and auto-sync |
170
+ | `chat --llm CMD` | Interactive memory-backed chat REPL |
158
171
  | `serve` | Start MCP server (requires `memctl[mcp]`) |
159
172
 
160
173
  ### Global Flags
@@ -312,6 +325,31 @@ Output includes file/chunk/size totals, per-folder breakdown, per-extension dist
312
325
 
313
326
  All implicit actions (mount, sync) are announced on stderr. `--quiet` suppresses them.
314
327
 
328
+ #### `memctl chat`
329
+
330
+ ```bash
331
+ memctl chat --llm CMD [--session] [--store] [--protocol passive|json|regex]
332
+ [--max-calls N] [--budget N] [--source FILE ...] [--quiet]
333
+ ```
334
+
335
+ Interactive memory-backed chat REPL. Each turn: FTS5 recall from the memory store, send context + question to the LLM, display the answer.
336
+
337
+ **Stateless by default.** Each question sees only the memory store — no hidden conversation state.
338
+
339
+ | Flag | Default | Description |
340
+ |------|---------|-------------|
341
+ | `--llm CMD` | *(required)* | LLM command (e.g. `"claude -p"`, `"ollama run granite3.1:2b"`) |
342
+ | `--protocol` | `passive` | LLM output protocol. `passive` = single-pass; `json` = iterative refinement |
343
+ | `--max-calls` | `1` | Max loop iterations per turn |
344
+ | `--session` | off | Enable in-memory session context (sliding window of recent Q&A) |
345
+ | `--history-turns` | `5` | Session window size (turns) |
346
+ | `--session-budget` | `4000` | Session block character limit |
347
+ | `--store` | off | Persist each answer as STM item |
348
+ | `--source FILE...` | *(none)* | Pre-ingest files before starting |
349
+ | `--tags` | `chat` | Tags for stored items (comma-separated) |
350
+
351
+ **stdout purity:** answers go to stdout only. Prompt, banner, and hints go to stderr.
352
+
315
353
  ---
316
354
 
317
355
  ## Environment Variables
@@ -378,6 +416,9 @@ memctl inspect src/ --json | jq '.extensions'
378
416
 
379
417
  # Inspect without syncing (use cached state)
380
418
  memctl inspect docs/ --no-sync --json
419
+
420
+ # Interactive chat with pre-ingested docs
421
+ memctl chat --llm "claude -p" --source docs/ --session --store
381
422
  ```
382
423
 
383
424
  ---
@@ -442,7 +483,8 @@ memctl/
442
483
  ├── mount.py Folder mount registration and management
443
484
  ├── sync.py Delta sync with 3-tier change detection
444
485
  ├── inspect.py Structural inspection and orchestration
445
- ├── cli.py 12 CLI commands
486
+ ├── chat.py Interactive memory-backed chat REPL
487
+ ├── cli.py 13 CLI commands
446
488
  ├── consolidate.py Deterministic merge (Jaccard clustering, no LLM)
447
489
  ├── proposer.py LLM output parsing (delimiter + regex)
448
490
  └── mcp/
@@ -451,7 +493,7 @@ memctl/
451
493
  └── server.py FastMCP server entry point
452
494
  ```
453
495
 
454
- 19 source files. ~7,300 lines. Zero compiled dependencies for core.
496
+ 20 source files. ~7,700 lines. Zero compiled dependencies for core.
455
497
 
456
498
  ### Memory Tiers
457
499
 
@@ -610,7 +652,7 @@ pip install memctl[dev]
610
652
  pytest tests/ -v
611
653
  ```
612
654
 
613
- 479 tests across 14 test files covering types, store, policy, ingest, text extraction, similarity, loop controller, mount, sync, inspect, forward compatibility, contracts, CLI (subprocess), and pipe composition.
655
+ 492 tests across 15 test files covering types, store, policy, ingest, text extraction, similarity, loop controller, mount, sync, inspect, chat, forward compatibility, contracts, CLI (subprocess), and pipe composition.
614
656
 
615
657
  ---
616
658
 
@@ -7,7 +7,7 @@ Policy-governed, content-addressed, and forward-compatible with RAGIX.
7
7
  Author: Olivier Vitrac, PhD, HDR | olivier.vitrac@adservio.fr | Adservio
8
8
  """
9
9
 
10
- __version__ = "0.3.0"
10
+ __version__ = "0.4.0"
11
11
 
12
12
  from memctl.types import (
13
13
  MemoryItem,
@@ -0,0 +1,414 @@
1
+ """
2
+ Interactive memory-backed chat REPL for memctl.
3
+
4
+ Bounded, deterministic, stateless by default. Each turn:
5
+ 1. Recalls from the memory store via FTS5
6
+ 2. Prepends session context (if --session)
7
+ 3. Sends to an LLM via the loop controller
8
+ 4. Displays the answer on stdout
9
+ 5. Optionally stores the answer as STM (if --store)
10
+
11
+ The LLM is never autonomous. The controller enforces bounds,
12
+ dedup, and convergence stopping — same as `memctl loop`.
13
+
14
+ Author: Olivier Vitrac, PhD, HDR | olivier.vitrac@adservio.fr | Adservio
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import sys
20
+ from dataclasses import dataclass, field
21
+ from typing import Callable, Optional
22
+
23
+ from memctl.loop import LoopResult
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Uncertainty markers (passive protocol hint)
27
+ # ---------------------------------------------------------------------------
28
+
29
+ _UNCERTAINTY_MARKERS = (
30
+ "insufficient",
31
+ "cannot find",
32
+ "not enough context",
33
+ "no information",
34
+ "unclear",
35
+ "unable to determine",
36
+ "no relevant",
37
+ "not available",
38
+ )
39
+
40
+ _REFINEMENT_HINT = (
41
+ "[info] Tip: rerun with --protocol json --max-calls 3 "
42
+ "to allow iterative refinement"
43
+ )
44
+
45
+
46
+ def _has_uncertainty(answer: str) -> bool:
47
+ """Return True if the answer contains high-uncertainty markers."""
48
+ lower = answer.lower()
49
+ return any(marker in lower for marker in _UNCERTAINTY_MARKERS)
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Session management
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ @dataclass
58
+ class ChatSession:
59
+ """In-memory sliding window of Q&A pairs."""
60
+
61
+ history: list[tuple[str, str]] = field(default_factory=list)
62
+ turn_count: int = 0
63
+
64
+
65
+ def format_session_context(
66
+ session: ChatSession,
67
+ history_turns: int = 5,
68
+ budget_chars: int = 4000,
69
+ ) -> str:
70
+ """Format recent session history as a context block.
71
+
72
+ Dual bound: at most *history_turns* pairs, and at most *budget_chars*
73
+ total characters. Oldest turns are trimmed first.
74
+
75
+ Returns:
76
+ Formatted session block, or empty string if no history.
77
+ """
78
+ if not session.history:
79
+ return ""
80
+
81
+ # Take last N turns
82
+ window = session.history[-history_turns:]
83
+
84
+ # Build from newest to oldest, trim if over budget
85
+ lines: list[str] = ["## Session History"]
86
+ total_chars = len(lines[0])
87
+
88
+ kept: list[tuple[str, str]] = []
89
+ for q, a in reversed(window):
90
+ block = f"\nQ: {q}\nA: {a}"
91
+ if total_chars + len(block) > budget_chars:
92
+ break
93
+ kept.append((q, a))
94
+ total_chars += len(block)
95
+
96
+ # Restore chronological order
97
+ kept.reverse()
98
+
99
+ if not kept:
100
+ return ""
101
+
102
+ for q, a in kept:
103
+ lines.append(f"\nQ: {q}")
104
+ lines.append(f"A: {a}")
105
+
106
+ return "\n".join(lines)
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Single turn (pure function — no I/O, no env vars, no global state)
111
+ # ---------------------------------------------------------------------------
112
+
113
+ # Default callable types for dependency injection
114
+ RecallerType = Callable[[str, str, int], list[dict]]
115
+ LoopRunnerType = Callable[..., LoopResult]
116
+
117
+
118
+ def _default_recaller(db_path: str, query: str, limit: int = 50) -> list[dict]:
119
+ """Default recaller: wraps loop.recall_items."""
120
+ from memctl.loop import recall_items
121
+ return recall_items(db_path, query, limit)
122
+
123
+
124
+ def _default_loop_runner(**kwargs) -> LoopResult:
125
+ """Default loop runner: wraps loop.run_loop."""
126
+ from memctl.loop import run_loop
127
+ return run_loop(**kwargs)
128
+
129
+
130
+ def chat_turn(
131
+ question: str,
132
+ llm_cmd: str,
133
+ *,
134
+ db_path: str,
135
+ session: Optional[ChatSession] = None,
136
+ history_turns: int = 5,
137
+ session_budget: int = 4000,
138
+ budget: int = 2200,
139
+ protocol: str = "passive",
140
+ max_calls: int = 1,
141
+ threshold: float = 0.92,
142
+ query_threshold: float = 0.90,
143
+ stable_steps: int = 2,
144
+ system_prompt: Optional[str] = None,
145
+ llm_mode: str = "stdin",
146
+ timeout: int = 300,
147
+ recaller: Optional[RecallerType] = None,
148
+ loop_runner: Optional[LoopRunnerType] = None,
149
+ ) -> str:
150
+ """Execute a single chat turn. Pure function — no I/O.
151
+
152
+ Args:
153
+ question: The user's question.
154
+ llm_cmd: Shell command to invoke the LLM.
155
+ db_path: Path to the SQLite database.
156
+ session: Optional ChatSession for turn-to-turn continuity.
157
+ history_turns: Max Q&A pairs from session to include.
158
+ session_budget: Max characters for session context block.
159
+ budget: Token budget for recall context.
160
+ protocol: LLM output protocol (passive/json/regex).
161
+ max_calls: Max loop iterations per turn.
162
+ threshold: Answer fixed-point similarity threshold.
163
+ query_threshold: Query cycle similarity threshold.
164
+ stable_steps: Consecutive stable steps for convergence.
165
+ system_prompt: Optional system prompt.
166
+ llm_mode: How to pass prompt to LLM (stdin/file).
167
+ timeout: LLM subprocess timeout in seconds.
168
+ recaller: Injectable recall function (for testing).
169
+ loop_runner: Injectable loop function (for testing).
170
+
171
+ Returns:
172
+ The LLM's answer string.
173
+ """
174
+ _recaller = recaller or _default_recaller
175
+ _loop_runner = loop_runner or _default_loop_runner
176
+
177
+ # 1. Recall from memory store
178
+ items = _recaller(db_path, question, 50)
179
+
180
+ # 2. Format recall items as context
181
+ from memctl.loop import merge_context
182
+ budget_chars = budget * 4
183
+ seen_ids: set[str] = set()
184
+ context, _, _ = merge_context("", items, seen_ids, budget_chars)
185
+
186
+ # 3. Prepend session context (if any)
187
+ if session is not None:
188
+ session_block = format_session_context(
189
+ session, history_turns, session_budget,
190
+ )
191
+ if session_block:
192
+ context = session_block + "\n\n" + context if context else session_block
193
+
194
+ # 4. Run the loop
195
+ result = _loop_runner(
196
+ initial_context=context,
197
+ query=question,
198
+ llm_cmd=llm_cmd,
199
+ db_path=db_path,
200
+ max_calls=max_calls,
201
+ threshold=threshold,
202
+ query_threshold=query_threshold,
203
+ stable_steps=stable_steps,
204
+ protocol=protocol,
205
+ llm_mode=llm_mode,
206
+ system_prompt=system_prompt,
207
+ budget=budget,
208
+ timeout=timeout,
209
+ quiet=True,
210
+ )
211
+
212
+ return result.answer
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # Answer storage
217
+ # ---------------------------------------------------------------------------
218
+
219
+
220
+ def _store_answer(
221
+ store,
222
+ question: str,
223
+ answer: str,
224
+ tags: list[str],
225
+ ) -> None:
226
+ """Store a chat answer as an STM memory item via policy.
227
+
228
+ Args:
229
+ store: Connected MemoryStore instance.
230
+ question: The user's question (used for title).
231
+ answer: The LLM's answer to store.
232
+ tags: Tags for the stored item.
233
+ """
234
+ from memctl.types import MemoryItem, MemoryProvenance
235
+ from memctl.policy import MemoryPolicy
236
+
237
+ # Ensure "chat" tag is present
238
+ all_tags = list(set(tags + ["chat"]))
239
+
240
+ item = MemoryItem(
241
+ title=question[:80],
242
+ content=answer,
243
+ tier="stm",
244
+ type="note",
245
+ tags=all_tags,
246
+ provenance=MemoryProvenance(
247
+ source_kind="chat",
248
+ source_id="memctl-chat",
249
+ ),
250
+ )
251
+
252
+ # Policy check
253
+ policy = MemoryPolicy()
254
+ from memctl.types import MemoryProposal
255
+ proposal = MemoryProposal(
256
+ title=item.title,
257
+ content=item.content,
258
+ tags=item.tags,
259
+ why_store="Chat answer stored by user request",
260
+ provenance_hint={"source_kind": "chat", "source_id": "memctl-chat"},
261
+ )
262
+ verdict = policy.evaluate_proposal(proposal)
263
+
264
+ if verdict.action == "reject":
265
+ return # silently skip policy-rejected answers
266
+
267
+ if verdict.action == "quarantine":
268
+ item.injectable = False
269
+
270
+ store.write_item(item, reason="chat-store")
271
+
272
+
273
+ # ---------------------------------------------------------------------------
274
+ # REPL
275
+ # ---------------------------------------------------------------------------
276
+
277
+
278
+ def chat_repl(
279
+ llm_cmd: str,
280
+ *,
281
+ db_path: str,
282
+ store_answers: bool = False,
283
+ session_enabled: bool = False,
284
+ history_turns: int = 5,
285
+ session_budget: int = 4000,
286
+ budget: int = 2200,
287
+ tags: list[str],
288
+ protocol: str = "passive",
289
+ max_calls: int = 1,
290
+ threshold: float = 0.92,
291
+ system_prompt: Optional[str] = None,
292
+ llm_mode: str = "stdin",
293
+ timeout: int = 300,
294
+ quiet: bool = False,
295
+ sources: Optional[list[str]] = None,
296
+ ) -> None:
297
+ """Run the interactive chat REPL.
298
+
299
+ Answers go to stdout. Prompt, banner, and hints go to stderr.
300
+
301
+ Args:
302
+ llm_cmd: LLM command string.
303
+ db_path: Path to the SQLite database.
304
+ store_answers: Persist each answer as STM.
305
+ session_enabled: Enable in-memory session context.
306
+ history_turns: Max Q&A pairs in session window.
307
+ session_budget: Max characters for session block.
308
+ budget: Token budget for recall context.
309
+ tags: Tags for stored items.
310
+ protocol: LLM output protocol.
311
+ max_calls: Max loop iterations per turn.
312
+ threshold: Answer fixed-point threshold.
313
+ system_prompt: Optional system prompt.
314
+ llm_mode: stdin or file.
315
+ timeout: LLM subprocess timeout.
316
+ quiet: Suppress progress.
317
+ sources: Files to pre-ingest before starting.
318
+ """
319
+ from memctl.store import MemoryStore
320
+
321
+ store = MemoryStore(db_path=db_path)
322
+
323
+ # Pre-ingest sources
324
+ if sources:
325
+ from memctl.ingest import ingest_file, IngestResult, resolve_sources
326
+ try:
327
+ resolved = resolve_sources(sources)
328
+ except FileNotFoundError as e:
329
+ print(f"[chat] Error: {e}", file=sys.stderr)
330
+ store.close()
331
+ sys.exit(1)
332
+
333
+ total = IngestResult()
334
+ for path in resolved:
335
+ r = ingest_file(store, path, format_mode="auto", injectable=True)
336
+ total.files_processed += r.files_processed
337
+ total.chunks_created += r.chunks_created
338
+
339
+ if not quiet:
340
+ print(
341
+ f"[chat] Ingested {total.chunks_created} chunks "
342
+ f"from {total.files_processed} file(s)",
343
+ file=sys.stderr,
344
+ )
345
+
346
+ # Banner
347
+ if not quiet:
348
+ print("memctl chat \u2014 Ctrl-D to exit, Ctrl-C to cancel current turn",
349
+ file=sys.stderr)
350
+ if session_enabled:
351
+ print("session: in-memory only (not persisted)", file=sys.stderr)
352
+
353
+ # Session state
354
+ session = ChatSession() if session_enabled else None
355
+ interactive = sys.stdin.isatty()
356
+
357
+ # REPL loop
358
+ try:
359
+ while True:
360
+ try:
361
+ # Prompt on stderr only if interactive (suppress for piped input)
362
+ if interactive:
363
+ print("> ", end="", file=sys.stderr, flush=True)
364
+ line = input()
365
+ except KeyboardInterrupt:
366
+ print("", file=sys.stderr)
367
+ continue
368
+ except EOFError:
369
+ break
370
+
371
+ question = line.strip()
372
+ if not question:
373
+ continue
374
+
375
+ # Execute turn
376
+ try:
377
+ answer = chat_turn(
378
+ question,
379
+ llm_cmd,
380
+ db_path=db_path,
381
+ session=session,
382
+ history_turns=history_turns,
383
+ session_budget=session_budget,
384
+ budget=budget,
385
+ protocol=protocol,
386
+ max_calls=max_calls,
387
+ threshold=threshold,
388
+ system_prompt=system_prompt,
389
+ llm_mode=llm_mode,
390
+ timeout=timeout,
391
+ )
392
+ except RuntimeError as e:
393
+ print(f"[chat] LLM error: {e}", file=sys.stderr)
394
+ continue
395
+
396
+ # Display answer (stdout only)
397
+ print(answer)
398
+ print() # blank line for readability
399
+
400
+ # Refinement hint (passive mode only)
401
+ if protocol == "passive" and _has_uncertainty(answer) and not quiet:
402
+ print(_REFINEMENT_HINT, file=sys.stderr)
403
+
404
+ # Store answer
405
+ if store_answers:
406
+ _store_answer(store, question, answer, tags)
407
+
408
+ # Update session
409
+ if session is not None:
410
+ session.history.append((question, answer))
411
+ session.turn_count += 1
412
+
413
+ finally:
414
+ store.close()