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.
- {memctl-0.3.0 → memctl-0.4.0}/PKG-INFO +47 -5
- {memctl-0.3.0 → memctl-0.4.0}/README.md +46 -4
- {memctl-0.3.0 → memctl-0.4.0}/memctl/__init__.py +1 -1
- memctl-0.4.0/memctl/chat.py +414 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl/cli.py +76 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl.egg-info/PKG-INFO +47 -5
- {memctl-0.3.0 → memctl-0.4.0}/memctl.egg-info/SOURCES.txt +2 -0
- {memctl-0.3.0 → memctl-0.4.0}/pyproject.toml +1 -1
- memctl-0.4.0/tests/test_chat.py +317 -0
- {memctl-0.3.0 → memctl-0.4.0}/tests/test_cli.py +37 -1
- {memctl-0.3.0 → memctl-0.4.0}/LICENSE +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl/config.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl/consolidate.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl/extract.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl/ingest.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl/inspect.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl/loop.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl/mcp/__init__.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl/mcp/formatting.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl/mcp/server.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl/mcp/tools.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl/mount.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl/policy.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl/proposer.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl/similarity.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl/store.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl/sync.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl/types.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl.egg-info/dependency_links.txt +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl.egg-info/entry_points.txt +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl.egg-info/requires.txt +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/memctl.egg-info/top_level.txt +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/setup.cfg +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/tests/test_contracts.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/tests/test_extract.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/tests/test_forward_compat.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/tests/test_ingest.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/tests/test_inspect.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/tests/test_loop.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/tests/test_mount.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/tests/test_pipe_compose.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/tests/test_policy.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/tests/test_similarity.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/tests/test_store.py +0 -0
- {memctl-0.3.0 → memctl-0.4.0}/tests/test_sync.py +0 -0
- {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
|
+
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.
|
|
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
|
-
├──
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
├──
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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()
|