cite-agent 1.3.7__tar.gz → 1.3.9__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.
Potentially problematic release.
This version of cite-agent might be problematic. Click here for more details.
- {cite_agent-1.3.7/cite_agent.egg-info → cite_agent-1.3.9}/PKG-INFO +1 -1
- cite_agent-1.3.9/cite_agent/__version__.py +1 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/cli.py +180 -7
- cite_agent-1.3.9/cite_agent/conversation_archive.py +152 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/enhanced_ai_agent.py +1299 -180
- {cite_agent-1.3.7 → cite_agent-1.3.9/cite_agent.egg-info}/PKG-INFO +1 -1
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent.egg-info/SOURCES.txt +11 -3
- {cite_agent-1.3.7 → cite_agent-1.3.9}/docs/BETA_LAUNCH_CHECKLIST.md +2 -0
- cite_agent-1.3.9/docs/BETA_PITCH_v1.3.9.md +95 -0
- cite_agent-1.3.9/docs/BETA_SHOWCASE_GUIDE.md +43 -0
- cite_agent-1.3.9/docs/DEV_NOTES_2025-10-30.md +8 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/setup.py +1 -1
- cite_agent-1.3.9/tests/enhanced/conftest.py +11 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/tests/enhanced/test_account_client.py +6 -5
- {cite_agent-1.3.7 → cite_agent-1.3.9}/tests/enhanced/test_archive_agent.py +30 -16
- cite_agent-1.3.9/tests/enhanced/test_autonomy_harness.py +124 -0
- cite_agent-1.3.9/tests/enhanced/test_conversation_archive.py +41 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/tests/enhanced/test_enhanced_agent_runtime.py +15 -64
- cite_agent-1.3.9/tests/enhanced/test_financial_planner.py +59 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/tests/enhanced/test_setup_config.py +1 -5
- {cite_agent-1.3.7 → cite_agent-1.3.9}/tests/integration_test.py +12 -12
- {cite_agent-1.3.7 → cite_agent-1.3.9}/tests/test_truth_seeking_comprehensive.py +1 -2
- cite_agent-1.3.7/cite_agent/__version__.py +0 -1
- {cite_agent-1.3.7 → cite_agent-1.3.9}/LICENSE +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/MANIFEST.in +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/README.md +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/__init__.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/__main__.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/account_client.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/agent_backend_only.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/ascii_plotting.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/auth.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/backend_only_client.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/cli_conversational.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/cli_enhanced.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/cli_workflow.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/dashboard.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/project_detector.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/rate_limiter.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/session_manager.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/setup_config.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/streaming_ui.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/telemetry.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/ui.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/updater.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/web_search.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/workflow.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent/workflow_integration.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent.egg-info/dependency_links.txt +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent.egg-info/entry_points.txt +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent.egg-info/requires.txt +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/cite_agent.egg-info/top_level.txt +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/docs/BETA_RELEASE_CHECKLIST.md +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/docs/ENHANCED_CAPABILITIES.md +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/docs/GROQ_RATE_LIMITS.md +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/docs/INSTALL.md +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/docs/PUBLISHING_PYPI.md +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/docs/SECURE_PACKAGING_GUIDE.md +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/docs/SECURITY_AUDIT.md +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/docs/USER_GETTING_STARTED.md +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/docs/playbooks/BETA_LAUNCH_PLAYBOOK.md +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/requirements.txt +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/setup.cfg +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/tests/beta_launch_test_suite.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/tests/enhanced/test_reasoning_engine.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/tests/enhanced/test_tool_framework.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/tests/session_affirmation.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/tests/test_cli_direct.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/tests/test_end_to_end.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/tests/test_setup_flow.py +0 -0
- /cite_agent-1.3.7/tests/test_version_1.0.4.py → /cite_agent-1.3.9/tests/test_version_1_0_4.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/tests/validation/test_accuracy_system.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/tests/validation/test_agent_live.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/tests/validation/test_backend_local.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/tests/validation/test_cerebras_comparison.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/tests/validation/test_improved_prompt.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/tests/validation/test_qualitative_robustness.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/tests/validation/test_qualitative_system.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/tests/validation/test_truth_seeking_chinese.py +0 -0
- {cite_agent-1.3.7 → cite_agent-1.3.9}/tests/validation/test_truth_seeking_real.py +0 -0
- /cite_agent-1.3.7/tests/validation/test_truth_seeking_comprehensive.py → /cite_agent-1.3.9/tests/validation/test_truth_seeking_validation.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.3.9"
|
|
@@ -6,13 +6,15 @@ Provides a terminal interface similar to cursor-agent
|
|
|
6
6
|
|
|
7
7
|
import argparse
|
|
8
8
|
import asyncio
|
|
9
|
+
import json
|
|
9
10
|
import os
|
|
10
11
|
import random
|
|
11
12
|
import sys
|
|
12
13
|
import time
|
|
14
|
+
import hashlib
|
|
13
15
|
from datetime import datetime
|
|
14
16
|
from pathlib import Path
|
|
15
|
-
from typing import Optional
|
|
17
|
+
from typing import Optional, Dict
|
|
16
18
|
|
|
17
19
|
from rich import box
|
|
18
20
|
from rich.console import Console
|
|
@@ -28,6 +30,26 @@ from .cli_workflow import WorkflowCLI
|
|
|
28
30
|
from .workflow import WorkflowManager, Paper, parse_paper_from_response
|
|
29
31
|
from .session_manager import SessionManager
|
|
30
32
|
|
|
33
|
+
PRESET_SCENARIOS: Dict[str, Dict[str, str]] = {
|
|
34
|
+
"Research sprint": {
|
|
35
|
+
"prompt": "Run a literature review on retrieval-augmented generation, summarise three key papers and cite sources.",
|
|
36
|
+
"highlight": "Archive API + guardrails"
|
|
37
|
+
},
|
|
38
|
+
"Data audit": {
|
|
39
|
+
"prompt": "Inspect sales_data.csv, perform exploratory stats, and flag any anomalies worth investigating.",
|
|
40
|
+
"highlight": "Shell analytics + guardrails"
|
|
41
|
+
},
|
|
42
|
+
"Financial briefing": {
|
|
43
|
+
"prompt": "Compare NVDA and AMD revenue and margin trends for the last 4 quarters using FinSight.",
|
|
44
|
+
"highlight": "FinSight multi-ticker"
|
|
45
|
+
},
|
|
46
|
+
"Team handoff": {
|
|
47
|
+
"prompt": "Summarise our last session and note any follow-up tasks saved in the archive for project alpha.",
|
|
48
|
+
"highlight": "Archive memory"
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
31
53
|
class NocturnalCLI:
|
|
32
54
|
"""Command Line Interface for Cite Agent"""
|
|
33
55
|
|
|
@@ -53,6 +75,18 @@ class NocturnalCLI:
|
|
|
53
75
|
"Remember the sandbox: prefix shell commands with [bold]![/] to execute safe utilities only.",
|
|
54
76
|
"If you see an auto-update notice, the CLI will restart itself to load the latest build.",
|
|
55
77
|
]
|
|
78
|
+
self._default_artifacts = Path("artifacts_autonomy.json")
|
|
79
|
+
|
|
80
|
+
def _record_session_event(self, success: bool) -> None:
|
|
81
|
+
try:
|
|
82
|
+
manager = TelemetryManager.get()
|
|
83
|
+
email = os.getenv("NOCTURNAL_ACCOUNT_EMAIL", "")
|
|
84
|
+
payload = {"success": bool(success)}
|
|
85
|
+
if email:
|
|
86
|
+
payload["user"] = hashlib.sha256(email.encode("utf-8")).hexdigest()[:16]
|
|
87
|
+
manager.record("session_login", payload)
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
56
90
|
|
|
57
91
|
def handle_user_friendly_session(self):
|
|
58
92
|
"""Handle session management with user-friendly interface"""
|
|
@@ -78,7 +112,9 @@ class NocturnalCLI:
|
|
|
78
112
|
# Handle user-friendly session management (skip prompts in non-interactive mode)
|
|
79
113
|
if not non_interactive:
|
|
80
114
|
if not self.handle_user_friendly_session():
|
|
115
|
+
self._record_session_event(False)
|
|
81
116
|
return False
|
|
117
|
+
self._record_session_event(True)
|
|
82
118
|
|
|
83
119
|
self._show_intro_panel()
|
|
84
120
|
|
|
@@ -196,6 +232,92 @@ class NocturnalCLI:
|
|
|
196
232
|
box=box.ROUNDED,
|
|
197
233
|
)
|
|
198
234
|
self.console.print(panel)
|
|
235
|
+
|
|
236
|
+
def show_presets(self) -> None:
|
|
237
|
+
table = Table(title="🚀 Beta Showcase Presets", box=box.ROUNDED, show_edge=True)
|
|
238
|
+
table.add_column("Scenario", style="bold cyan")
|
|
239
|
+
table.add_column("Prompt", style="white")
|
|
240
|
+
table.add_column("Highlights", style="magenta")
|
|
241
|
+
for name, payload in PRESET_SCENARIOS.items():
|
|
242
|
+
table.add_row(name, payload["prompt"], payload["highlight"])
|
|
243
|
+
self.console.print(table)
|
|
244
|
+
self.console.print("[dim]Tip: run [/dim][bold]nocturnal \"<prompt>\"[/bold][dim] to execute a preset immediately.[/dim]")
|
|
245
|
+
|
|
246
|
+
def show_metrics(self, artifacts: Optional[Path] = None) -> None:
|
|
247
|
+
artifacts_path = artifacts or self._default_artifacts
|
|
248
|
+
if not artifacts_path.exists():
|
|
249
|
+
self.console.print(
|
|
250
|
+
"[warning]No metrics file found.[/warning] Run [bold]python3 scripts/run_beta_showcase.py[/bold] first."
|
|
251
|
+
)
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
payload = json.loads(artifacts_path.read_text())
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
self.console.print(f"[error]Failed to parse {artifacts_path}: {exc}[/error]")
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
metrics = payload.get("_metrics")
|
|
261
|
+
if not metrics:
|
|
262
|
+
self.console.print(
|
|
263
|
+
"[warning]Metrics summary missing. Regenerate the file with [/warning]"
|
|
264
|
+
"[bold]python3 scripts/run_beta_showcase.py[/bold]."
|
|
265
|
+
)
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
table = Table(title="📊 Beta Harness Summary", box=box.ROUNDED)
|
|
269
|
+
table.add_column("Metric", style="bold green")
|
|
270
|
+
table.add_column("Value", style="white")
|
|
271
|
+
table.add_row("Scenarios", str(metrics.get("scenario_count", "-")))
|
|
272
|
+
elapsed = metrics.get("total_elapsed", 0.0)
|
|
273
|
+
table.add_row("Total elapsed", f"{elapsed:.2f}s")
|
|
274
|
+
guard = metrics.get("guardrail_pass_rate", 0.0)
|
|
275
|
+
table.add_row("Guardrail pass rate", f"{guard:.1%}")
|
|
276
|
+
|
|
277
|
+
tool_usage = metrics.get("tool_usage", {})
|
|
278
|
+
if tool_usage:
|
|
279
|
+
usage_lines = [f"{tool}: {count}" for tool, count in tool_usage.items()]
|
|
280
|
+
table.add_row("Tool invocations", "\n".join(usage_lines))
|
|
281
|
+
|
|
282
|
+
self.console.print(table)
|
|
283
|
+
|
|
284
|
+
guardrail_findings = []
|
|
285
|
+
for name, scenario in payload.items():
|
|
286
|
+
if not isinstance(scenario, dict) or name.startswith("_"):
|
|
287
|
+
continue
|
|
288
|
+
quality = scenario.get("quality_checks")
|
|
289
|
+
if not quality:
|
|
290
|
+
continue
|
|
291
|
+
if not all(quality.values()):
|
|
292
|
+
guardrail_findings.append((name, quality))
|
|
293
|
+
|
|
294
|
+
if guardrail_findings:
|
|
295
|
+
warn_table = Table(title="⚠️ Guardrails needing attention", box=box.ROUNDED, style="yellow")
|
|
296
|
+
warn_table.add_column("Scenario", style="bold")
|
|
297
|
+
warn_table.add_column("Checks", style="white")
|
|
298
|
+
for scenario, checks in guardrail_findings:
|
|
299
|
+
failed = [f"{key}={val}" for key, val in checks.items()]
|
|
300
|
+
warn_table.add_row(scenario, ", ".join(failed))
|
|
301
|
+
self.console.print(warn_table)
|
|
302
|
+
else:
|
|
303
|
+
self.console.print("[success]All guardrails passed.[/success]")
|
|
304
|
+
|
|
305
|
+
def show_token_report(self) -> None:
|
|
306
|
+
try:
|
|
307
|
+
from scripts.token_report import build_token_report
|
|
308
|
+
except Exception as exc: # pragma: no cover - import convenience
|
|
309
|
+
self.console.print(f"[error]Failed to import token report tool: {exc}[/error]")
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
root = Path(os.getenv("NOCTURNAL_HOME", str(Path.home() / ".nocturnal_archive")))
|
|
313
|
+
report = build_token_report(root)
|
|
314
|
+
table = Table(title="🪙 Token Usage", box=box.ROUNDED)
|
|
315
|
+
table.add_column("User (hashed)", style="cyan")
|
|
316
|
+
table.add_column("Tokens", style="white", justify="right")
|
|
317
|
+
for user, tokens in report["per_user"].items():
|
|
318
|
+
table.add_row(user, f"{tokens:.0f}")
|
|
319
|
+
self.console.print(table)
|
|
320
|
+
self.console.print(f"[dim]Total tokens: {report['total_tokens']:.0f}[/dim]")
|
|
199
321
|
|
|
200
322
|
def _enforce_latest_build(self):
|
|
201
323
|
"""Ensure the CLI is running the most recent published build."""
|
|
@@ -309,25 +431,43 @@ class NocturnalCLI:
|
|
|
309
431
|
try:
|
|
310
432
|
from rich.spinner import Spinner
|
|
311
433
|
from rich.live import Live
|
|
312
|
-
|
|
313
|
-
# Show
|
|
314
|
-
|
|
434
|
+
|
|
435
|
+
# Show detailed progress indicator
|
|
436
|
+
spinner = Spinner("dots", text="[dim]Processing query...[/dim]")
|
|
437
|
+
live = Live(spinner, console=self.console, transient=True)
|
|
438
|
+
live.start()
|
|
439
|
+
|
|
440
|
+
try:
|
|
315
441
|
request = ChatRequest(
|
|
316
442
|
question=user_input,
|
|
317
443
|
user_id="cli_user",
|
|
318
444
|
conversation_id=self.session_id
|
|
319
445
|
)
|
|
320
|
-
|
|
446
|
+
|
|
447
|
+
# Update spinner based on query type
|
|
448
|
+
if any(kw in user_input.lower() for kw in ['read', 'show', 'file', 'cat']):
|
|
449
|
+
spinner.update(text="[cyan]📄 Reading file...[/cyan]")
|
|
450
|
+
elif any(kw in user_input.lower() for kw in ['list', 'ls', 'find', 'search']):
|
|
451
|
+
spinner.update(text="[cyan]🔍 Searching files...[/cyan]")
|
|
452
|
+
elif any(kw in user_input.lower() for kw in ['python', 'calculate', 'run', 'execute']):
|
|
453
|
+
spinner.update(text="[cyan]⚙️ Executing code...[/cyan]")
|
|
454
|
+
elif any(kw in user_input.lower() for kw in ['research', 'paper', 'find', 'archive']):
|
|
455
|
+
spinner.update(text="[cyan]🔬 Searching research database...[/cyan]")
|
|
456
|
+
else:
|
|
457
|
+
spinner.update(text="[cyan]🤖 Thinking...[/cyan]")
|
|
458
|
+
|
|
321
459
|
response = await self.agent.process_request(request)
|
|
460
|
+
finally:
|
|
461
|
+
live.stop()
|
|
322
462
|
|
|
323
463
|
# Print response with typing effect for natural feel
|
|
324
464
|
self.console.print("[bold violet]🤖 Agent[/]: ", end="", highlight=False)
|
|
325
465
|
|
|
326
|
-
# Character-by-character streaming (like ChatGPT)
|
|
466
|
+
# Character-by-character streaming (like ChatGPT) - faster for long responses
|
|
327
467
|
import time
|
|
328
468
|
for char in response.response:
|
|
329
469
|
self.console.print(char, end="", style="white")
|
|
330
|
-
time.sleep(0.
|
|
470
|
+
time.sleep(0.003) # 3ms per character (~333 chars/sec) - faster than before
|
|
331
471
|
|
|
332
472
|
self.console.print() # Newline after response
|
|
333
473
|
|
|
@@ -732,6 +872,24 @@ Examples:
|
|
|
732
872
|
action='store_true',
|
|
733
873
|
help='Show recent query history'
|
|
734
874
|
)
|
|
875
|
+
|
|
876
|
+
parser.add_argument(
|
|
877
|
+
'--presets',
|
|
878
|
+
action='store_true',
|
|
879
|
+
help='Show curated beta showcase prompts'
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
parser.add_argument(
|
|
883
|
+
'--metrics',
|
|
884
|
+
action='store_true',
|
|
885
|
+
help='Display the latest autonomy harness metrics summary'
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
parser.add_argument(
|
|
889
|
+
'--token-report',
|
|
890
|
+
action='store_true',
|
|
891
|
+
help='Print aggregated token usage from telemetry logs'
|
|
892
|
+
)
|
|
735
893
|
|
|
736
894
|
parser.add_argument(
|
|
737
895
|
'--search-library',
|
|
@@ -772,6 +930,21 @@ Examples:
|
|
|
772
930
|
print("AI Research Assistant with real data integration")
|
|
773
931
|
return
|
|
774
932
|
|
|
933
|
+
if args.presets:
|
|
934
|
+
cli = NocturnalCLI()
|
|
935
|
+
cli.show_presets()
|
|
936
|
+
return
|
|
937
|
+
|
|
938
|
+
if args.metrics:
|
|
939
|
+
cli = NocturnalCLI()
|
|
940
|
+
cli.show_metrics()
|
|
941
|
+
return
|
|
942
|
+
|
|
943
|
+
if args.token_report:
|
|
944
|
+
cli = NocturnalCLI()
|
|
945
|
+
cli.show_token_report()
|
|
946
|
+
return
|
|
947
|
+
|
|
775
948
|
if args.tips or (args.query and args.query.lower() == "tips" and not args.interactive):
|
|
776
949
|
cli = NocturnalCLI()
|
|
777
950
|
cli.show_tips()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Lightweight persistent archive for conversation summaries."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import hashlib
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import List, Dict, Any, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ArchiveEntry:
|
|
17
|
+
"""Serialized representation of a conversation turn summary."""
|
|
18
|
+
|
|
19
|
+
timestamp: str
|
|
20
|
+
question: str
|
|
21
|
+
summary: str
|
|
22
|
+
tools_used: List[str]
|
|
23
|
+
citations: Optional[List[str]] = None
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
26
|
+
data: Dict[str, Any] = {
|
|
27
|
+
"timestamp": self.timestamp,
|
|
28
|
+
"question": self.question,
|
|
29
|
+
"summary": self.summary,
|
|
30
|
+
"tools_used": list(self.tools_used),
|
|
31
|
+
}
|
|
32
|
+
if self.citations:
|
|
33
|
+
data["citations"] = list(self.citations)
|
|
34
|
+
return data
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def from_dict(payload: Dict[str, Any]) -> "ArchiveEntry":
|
|
38
|
+
return ArchiveEntry(
|
|
39
|
+
timestamp=payload.get("timestamp", datetime.now(timezone.utc).isoformat()),
|
|
40
|
+
question=payload.get("question", ""),
|
|
41
|
+
summary=payload.get("summary", ""),
|
|
42
|
+
tools_used=list(payload.get("tools_used", [])),
|
|
43
|
+
citations=list(payload.get("citations", [])) or None,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ConversationArchive:
|
|
48
|
+
"""Stores compact conversation summaries for long-running research threads."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
root: Optional[Path] = None,
|
|
53
|
+
enabled: Optional[bool] = None,
|
|
54
|
+
max_entries: int = 30,
|
|
55
|
+
) -> None:
|
|
56
|
+
self.enabled = True if enabled is None else bool(enabled)
|
|
57
|
+
self.max_entries = max(1, max_entries)
|
|
58
|
+
env_root = os.getenv("CITE_AGENT_ARCHIVE_DIR")
|
|
59
|
+
final_root = root or Path(env_root) if env_root else root
|
|
60
|
+
self.root = Path(final_root or (Path.home() / ".cite_agent" / "conversation_archive"))
|
|
61
|
+
if self.enabled:
|
|
62
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _hash_identifier(identifier: str) -> str:
|
|
66
|
+
digest = hashlib.sha256(identifier.encode("utf-8")).hexdigest()
|
|
67
|
+
return digest[:16]
|
|
68
|
+
|
|
69
|
+
def _conversation_path(self, user_id: str, conversation_id: str) -> Path:
|
|
70
|
+
user_hash = self._hash_identifier(user_id or "anonymous")
|
|
71
|
+
convo_hash = self._hash_identifier(conversation_id or "default")
|
|
72
|
+
return self.root / f"{user_hash}-{convo_hash}.json"
|
|
73
|
+
|
|
74
|
+
def record_entry(
|
|
75
|
+
self,
|
|
76
|
+
user_id: str,
|
|
77
|
+
conversation_id: str,
|
|
78
|
+
question: str,
|
|
79
|
+
summary: str,
|
|
80
|
+
tools_used: Optional[List[str]] = None,
|
|
81
|
+
citations: Optional[List[str]] = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
if not self.enabled:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
entry = ArchiveEntry(
|
|
87
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
88
|
+
question=question.strip(),
|
|
89
|
+
summary=summary.strip(),
|
|
90
|
+
tools_used=list(tools_used or []),
|
|
91
|
+
citations=list(citations or []) or None,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
path = self._conversation_path(user_id, conversation_id)
|
|
95
|
+
entries: List[ArchiveEntry] = []
|
|
96
|
+
if path.exists():
|
|
97
|
+
try:
|
|
98
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
99
|
+
entries = [ArchiveEntry.from_dict(item) for item in data]
|
|
100
|
+
except Exception:
|
|
101
|
+
entries = []
|
|
102
|
+
|
|
103
|
+
entries.append(entry)
|
|
104
|
+
entries = entries[-self.max_entries:]
|
|
105
|
+
|
|
106
|
+
serialized = [item.to_dict() for item in entries]
|
|
107
|
+
path.write_text(json.dumps(serialized, indent=2), encoding="utf-8")
|
|
108
|
+
|
|
109
|
+
def get_recent_context(
|
|
110
|
+
self,
|
|
111
|
+
user_id: str,
|
|
112
|
+
conversation_id: str,
|
|
113
|
+
limit: int = 3,
|
|
114
|
+
) -> str:
|
|
115
|
+
if not self.enabled:
|
|
116
|
+
return ""
|
|
117
|
+
|
|
118
|
+
path = self._conversation_path(user_id, conversation_id)
|
|
119
|
+
if not path.exists():
|
|
120
|
+
return ""
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
124
|
+
except Exception:
|
|
125
|
+
return ""
|
|
126
|
+
|
|
127
|
+
if not isinstance(data, list):
|
|
128
|
+
return ""
|
|
129
|
+
|
|
130
|
+
entries = [ArchiveEntry.from_dict(item) for item in data][-max(1, limit):]
|
|
131
|
+
if not entries:
|
|
132
|
+
return ""
|
|
133
|
+
|
|
134
|
+
lines = ["Archived context from previous sessions:"]
|
|
135
|
+
for item in entries:
|
|
136
|
+
snippet = item.summary.strip().replace("\n", " ")
|
|
137
|
+
if len(snippet) > 220:
|
|
138
|
+
snippet = snippet[:217].rstrip() + "..."
|
|
139
|
+
lines.append(f"• {item.timestamp[:19]} — {snippet}")
|
|
140
|
+
return "\n".join(lines)
|
|
141
|
+
|
|
142
|
+
def clear_conversation(self, user_id: str, conversation_id: str) -> None:
|
|
143
|
+
if not self.enabled:
|
|
144
|
+
return
|
|
145
|
+
path = self._conversation_path(user_id, conversation_id)
|
|
146
|
+
if path.exists():
|
|
147
|
+
path.unlink()
|
|
148
|
+
|
|
149
|
+
def list_conversations(self) -> List[str]:
|
|
150
|
+
if not self.enabled or not self.root.exists():
|
|
151
|
+
return []
|
|
152
|
+
return [p.name for p in self.root.glob("*.json")]
|