Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
339 changes: 138 additions & 201 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ dev = [
[project.scripts]
shellllm-comma = "shellllm.comma:main"
shellllm-ask = "shellllm.ask:main"
shellllm-search = "shellllm.search:main"
shellllm-recall = "shellllm.recall:main"

[project.urls]
Homepage = "https://github.com/FrancoisChastel/shellllm"
Expand Down
61 changes: 61 additions & 0 deletions src/shellllm/archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,67 @@ def count(self) -> int:
row = conn.execute("SELECT COUNT(*) FROM archives").fetchone()
return int(row[0]) if row else 0

# ── Browse ---------------------------------------------------------

def recent(
self,
*,
limit: int = 20,
cmd_filter: str | None = None,
) -> list[ArchiveHit]:
"""Most-recent archives, no FTS query — for `??? --archives`.

Use this to browse what's been archived without a specific
recall query in mind. The snippet field is synthesized from
the head of the content (FTS5 ``snippet()`` is only available
on a MATCH expression).
"""
sql = "SELECT id, archived_at, cmd, last_pwd, last_date, turn_count, content FROM archives"
params: list[Any] = []
if cmd_filter:
sql += " WHERE cmd = ?"
params.append(cmd_filter)
sql += " ORDER BY archived_at DESC LIMIT ?"
params.append(limit)

with self._conn() as conn:
rows = conn.execute(sql, params).fetchall()

return [
ArchiveHit(
id=int(r[0]),
archived_at=float(r[1]),
cmd=str(r[2]),
last_pwd=str(r[3] or ""),
last_date=str(r[4] or ""),
turn_count=int(r[5]),
content=str(r[6]),
snippet=_truncate_snippet(str(r[6])),
)
for r in rows
]

def get(self, archive_id: int) -> ArchiveHit | None:
"""Fetch one archive by id, or None — for `??? --show <id>`."""
with self._conn() as conn:
row = conn.execute(
"SELECT id, archived_at, cmd, last_pwd, last_date, "
"turn_count, content FROM archives WHERE id = ?",
(archive_id,),
).fetchone()
if row is None:
return None
return ArchiveHit(
id=int(row[0]),
archived_at=float(row[1]),
cmd=str(row[2]),
last_pwd=str(row[3] or ""),
last_date=str(row[4] or ""),
turn_count=int(row[5]),
content=str(row[6]),
snippet=_truncate_snippet(str(row[6])),
)

# ── Searches -------------------------------------------------------

def search(
Expand Down
119 changes: 31 additions & 88 deletions src/shellllm/ask.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,20 @@
"`WallViolation`), respect the refusal and reason from what you have."
)

# Used when the user passes ``--web`` / ``-w``. Same agent, same tools;
# just a stronger nudge so the very first action is a web search.
ASK_WEB_SYSTEM = (
"You answer the user's question by searching the web. Start every "
"response by calling `web_search` with a focused query derived from "
"the question. If a result clearly contains the answer, follow it by "
"calling `fetch_url` on its URL to read the page in full — don't "
"answer from snippets alone when a fetch would give you the real "
"content. You also have `read_file` for files in $HOME or $PWD if "
"useful. Write a concise markdown answer and cite the URLs you used "
"as a short list at the end. If a tool refuses, reason from what you "
"have."
)

# Back-compat alias for any external importers.
SYSTEM = ASK_SYSTEM

Expand Down Expand Up @@ -496,18 +510,17 @@ def _print_usage(label: str, *, to: Any = None) -> None:
out = to or sys.stdout
out.write(
f"usage: {label} <question>\n"
f" {label} --web <question> force web-first this turn\n"
f" {label} --new <question> start a fresh session\n"
f" {label} --reset drop current session\n"
f" {label} --history print session transcript\n"
f" {label} --compact force compaction\n"
f" {label} --remember '<fact>' save long-term fact\n"
f" {label} --memories list saved facts\n"
f" {label} --forget <n> drop fact #n\n"
f" {label} --recall '<query>' search archived sessions\n"
f" {label} --auto-recall <question> inject archive hits as context this turn\n"
f" {label} --no-auto-recall <q> skip recall this turn (override env)\n"
f" {label} --mem | --no-mem force claude-mem on/off for this call\n"
f" {label} --help show this message\n"
f" {label} --reset drop current session\n"
f" {label} --history print session transcript\n"
f" {label} --compact force compaction\n"
f" {label} --auto-recall <q> inject archive hits as context\n"
f" {label} --no-auto-recall <q> skip recall this turn\n"
f" {label} --mem | --no-mem force claude-mem on/off for this call\n"
f" {label} --help show this message\n"
f"\n"
f"For facts and cross-session recall, see `??? help`.\n"
)


Expand Down Expand Up @@ -553,33 +566,17 @@ def _consume_flag(flag: str) -> bool:
return True
return False

def _consume_value(flag: str) -> str | None:
if flag in args:
i = args.index(flag)
if i + 1 < len(args):
value = args[i + 1]
del args[i : i + 2]
return value
del args[i]
return None

def _consume_tail(flag: str) -> str | None:
"""Consume the flag and everything after it as a single string.

Used for ``--remember`` so ``? --remember the project uses python``
works without forcing the user to quote the fact.
"""
if flag in args:
i = args.index(flag)
tail = args[i + 1 :]
del args[i:]
return " ".join(tail).strip() if tail else ""
return None

if _consume_flag("--help") or _consume_flag("-h"):
_print_usage(err_label)
return 0

# --web / -w flips the system prompt to web-first for this single
# turn. The agent loop is otherwise unchanged; the session stays
# tagged cmd="ask" so web-forced turns mix with local-knowledge
# ones in the same per-pane thread.
if _consume_flag("--web") or _consume_flag("-w"):
system = ASK_WEB_SYSTEM

# --mem / --no-mem force the claude-mem integration on or off for
# this invocation, overriding env vars.
if _consume_flag("--no-mem"):
Expand Down Expand Up @@ -608,20 +605,6 @@ def _consume_tail(flag: str) -> str | None:
print(f"{cmd} session reset.")
return 0

recall_query = _consume_tail("--recall")
if recall_query is not None:
if not recall_query:
sys.stderr.write(f"{_RED}{cmd} error:{_RESET} --recall needs a query\n")
return 2
query_vec = _safe_embed(recall_query)
hits = archive.search(recall_query, limit=10, query_embedding=query_vec)
if not hits:
print(f"(no archive hits for {recall_query!r})")
return 0
for i, hit in enumerate(hits, 1):
sys.stdout.write(_format_recall_hit(i, hit))
return 0

if _consume_flag("--history"):
_print_history(session)
return 0
Expand All @@ -640,46 +623,6 @@ def _consume_tail(flag: str) -> str | None:
)
return 0

fact = _consume_tail("--remember")
if fact is not None:
try:
stored = memory.add(fact)
except ValueError as exc:
sys.stderr.write(f"{_RED}{cmd} error:{_RESET} {exc}\n")
return 2
# Mirror to claude-mem as a long-lived user-fact observation.
# Local JSONL stays the source of truth for offline use.
claude_mem.record_observation_async(
stored.text,
kind="user-fact",
metadata={"source": "shellllm --remember"},
)
print(f"remembered: {stored.text}")
return 0

if _consume_flag("--memories"):
facts = memory.load()
if not facts:
print("(no remembered facts)")
return 0
for i, f in enumerate(facts, 1):
print(f"{i:>2}. {f.text}")
return 0

forget_value = _consume_value("--forget")
if forget_value is not None:
try:
idx = int(forget_value)
except ValueError:
sys.stderr.write(f"{_RED}{cmd} error:{_RESET} --forget needs an integer index\n")
return 2
removed = memory.forget(idx)
if removed is None:
sys.stderr.write(f"{_RED}{cmd} error:{_RESET} no fact at index {idx}\n")
return 2
print(f"forgot: {removed.text}")
return 0

new_session_requested = _consume_flag("--new")
if new_session_requested:
session.archive_and_reset(archive=archive, embed_fn=_safe_embed)
Expand Down
Loading
Loading