-
Notifications
You must be signed in to change notification settings - Fork 229
Expand file tree
/
Copy pathsession-start.sh
More file actions
executable file
·323 lines (287 loc) · 14 KB
/
Copy pathsession-start.sh
File metadata and controls
executable file
·323 lines (287 loc) · 14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
#!/usr/bin/env bash
#
# SessionStart hook — brief Claude from Basic Memory at the start of a session.
#
# This is the read side of the memory bridge: it puts the most relevant slice of
# the durable knowledge graph in front of Claude before the first prompt, so the
# session starts oriented instead of cold.
#
# Reads (all structured, all best-effort):
# - the primary project's active tasks + open decisions
# - open decisions from each configured shared/team project (secondaryProjects +
# teamProjects), queried in parallel — this is the Phase 4 "recall reads across
# the team" capability. Reads only; capture never touches a shared project.
#
# Contract: advisory, must NEVER disrupt a session. Every failure path exits 0 with
# no output. SessionStart adds plain stdout to Claude's context (verified — Q4),
# capped at 10,000 chars, so the brief stays small and bounded.
set -u
# --- Read the hook payload (stdin is JSON: cwd, source, session_id, ...) ---
# stdin can only be consumed once; capture it before anything else touches it.
input="$(cat 2>/dev/null || true)"
# --- Resolve how to invoke the Basic Memory CLI ---
# Prefer an explicit command when the host configured one, then a binary on PATH
# (fast — no per-call env resolution). Fall back to uvx / uv
# so the hook still works when Basic Memory was connected only as an ephemeral
# `uvx basic-memory mcp` server (the MCP setup our README recommends) with no
# persistent CLI installed — the uv cache is already warm from running the server.
# Trigger: none of basic-memory / bm / uvx / uv on PATH → BM isn't usable here.
# Outcome: silent no-op (the plugin must be invisible to non-BM users).
if [[ -n "${BM_BIN:-}" ]]; then
BM="$BM_BIN"
elif command -v basic-memory >/dev/null 2>&1; then
BM="basic-memory"
elif command -v bm >/dev/null 2>&1; then
BM="bm"
elif command -v uvx >/dev/null 2>&1; then
BM="uvx basic-memory"
elif command -v uv >/dev/null 2>&1; then
BM="uv tool run basic-memory"
else
exit 0
fi
# Everything else runs in one Python pass: parse config, run the queries, format
# the brief. Python is a guaranteed dependency (basic-memory requires it) and
# avoids brittle shell JSON wrangling. The payload and binary path cross over via
# the environment to sidestep argument-quoting issues.
BM_HOOK_INPUT="$input" BM_BIN="$BM" python3 <<'PY' 2>/dev/null || exit 0
import json
import os
import re
import shlex
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
# May be a single binary ("basic-memory") or a multi-token launcher
# ("uvx basic-memory"); split so it prepends cleanly onto each command list.
bm_cmd = shlex.split(os.environ.get("BM_BIN") or "basic-memory")
# Cloud project refs come in two unambiguous forms (names collide across
# workspaces, so a bare name won't route): a workspace-qualified name like
# "my-team-2/notes", or an external_id UUID. Detect the UUID to pick the flag.
UUID_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE
)
# Cap how many shared projects we read per session — bounds latency and output.
MAX_SHARED = 6
# --- Resolve the working directory from the payload ---
try:
payload = json.loads(os.environ.get("BM_HOOK_INPUT") or "{}")
except Exception:
payload = {}
cwd = payload.get("cwd") or os.getcwd()
# --- Load plugin config from .claude settings (local overrides committed) ---
# Precedence (lowest to highest): the user-level ~/.claude/settings.json, then
# the project's .claude/settings.json and .claude/settings.local.json. A single
# user-level basicMemory block can cover every project without running setup per
# repo; any project can still pin its own mapping, which wins. We mirror Claude
# Code's real sources: user level is settings.json only (there is no user-level
# settings.local.json), local settings are project-scoped. Because the hook cwd
# can be a repo subdirectory, we walk ancestors to the nearest .claude config so
# a project-root mapping is honoured instead of skipped. `found` is True if any
# file declared a basicMemory block — its presence is the first-run sentinel
# (setup writing it stops the nudge below).
def _read_block(path):
try:
with open(path) as fh:
block = json.load(fh).get("basicMemory")
except Exception:
return None
return block if isinstance(block, dict) else None
def _project_dir(directory):
# Nearest ancestor (including directory) holding a .claude settings file.
d = os.path.abspath(directory)
while True:
for name in ("settings.json", "settings.local.json"):
if os.path.isfile(os.path.join(d, ".claude", name)):
return d
parent = os.path.dirname(d)
if parent == d:
return os.path.abspath(directory)
d = parent
def load_settings(directory):
merged = {}
found = False
home = os.path.expanduser("~")
sources = [(home, ("settings.json",))]
project = _project_dir(directory) # already absolute
if project != home:
sources.append((project, ("settings.json", "settings.local.json")))
for d, names in sources:
for name in names:
block = _read_block(os.path.join(d, ".claude", name))
if block is not None:
found = True
merged.update(block)
return merged, found
cfg, configured = load_settings(cwd)
primary_project = (cfg.get("primaryProject") or "").strip()
recall_timeframe = cfg.get("recallTimeframe") or "3d"
default_prompt = (
"You have Basic Memory available for this project. Before answering recall "
'questions ("what did we decide", "where did we leave off"), search the graph '
"first — prefer structured filters (search_notes with type/status). When the "
"user makes a material decision, capture it as a note with type: decision. "
"Cite permalinks when referencing prior work."
)
recall_prompt = cfg.get("recallPrompt") or default_prompt
# Placement guidance — surfaced in the brief below so the output style's "follow the
# project's stored placement conventions" reflex has something concrete to follow.
# Without this, setup writes them but they never reach Claude (dead config).
placement_conventions = (cfg.get("placementConventions") or "").strip()
capture_folder = (cfg.get("captureFolder") or "sessions").strip()
# --- Resolve the shared/team read set ---
# secondaryProjects (read-only recall sources) + teamProjects keys (share targets,
# also read for recall). Dedup, preserve order, cap. These are read only — the
# capture hooks never write to them.
shared_refs = []
# Guard the JSON types: a misconfigured string would otherwise be iterated
# character-by-character, firing a bogus per-character query for each one.
secondary = cfg.get("secondaryProjects")
secondary = secondary if isinstance(secondary, list) else []
team = cfg.get("teamProjects")
team = team if isinstance(team, dict) else {}
for ref in list(secondary) + list(team.keys()):
if isinstance(ref, str) and ref.strip() and ref.strip() != primary_project:
if ref.strip() not in shared_refs:
shared_refs.append(ref.strip())
shared_capped = len(shared_refs) > MAX_SHARED
shared_refs = shared_refs[:MAX_SHARED]
# --- Structured query helper (best-effort, per-call timeout) ---
# project_ref=None routes to the user's default project (zero-config usefulness).
# A UUID ref routes via --project-id; a qualified name via --project.
def search(filters, project_ref=None, timeout=10):
cmd = [*bm_cmd, "tool", "search-notes", *filters, "--page-size", "5"]
if project_ref:
flag = "--project-id" if UUID_RE.match(project_ref) else "--project"
cmd += [flag, project_ref]
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if out.returncode != 0:
return None
return json.loads(out.stdout)
except Exception:
return None
ACTIVE_TASKS = ["--type", "task", "--status", "active"]
OPEN_DECISIONS = ["--type", "decision", "--status", "open"]
# Recent session checkpoints carry the resume cursor. This is the one query the
# `recallTimeframe` window applies to — tasks and decisions are status-scoped (an
# old open decision is still open), but "recent sessions" is inherently time-scoped.
RECENT_SESSIONS = ["--type", "session", "--after_date", recall_timeframe]
# --- Run everything concurrently ---
# Cloud reads cost a network round-trip each; parallelism keeps total wall-clock at
# ~one query instead of the sum. Each call is independently best-effort.
# Size the pool to cover every submitted search (3 primary + up to MAX_SHARED),
# so none queues — a queued call could otherwise serialize behind a slow one and
# push the hook past Claude Code's SessionStart timeout before the brief prints.
with ThreadPoolExecutor(max_workers=3 + MAX_SHARED) as pool:
fut_tasks = pool.submit(search, ACTIVE_TASKS, primary_project or None)
fut_decisions = pool.submit(search, OPEN_DECISIONS, primary_project or None)
fut_sessions = pool.submit(search, RECENT_SESSIONS, primary_project or None)
fut_shared = {ref: pool.submit(search, OPEN_DECISIONS, ref) for ref in shared_refs}
primary_tasks = fut_tasks.result()
primary_decisions = fut_decisions.result()
primary_sessions = fut_sessions.result()
shared_results = {ref: fut.result() for ref, fut in fut_shared.items()}
# The first-run nudge — shown until setup writes a basicMemory config block.
setup_nudge = (
"_Basic Memory isn't set up for this project yet. Run "
"`/basic-memory:bm-setup` (~2 min) to configure session briefings and checkpoints._"
)
# Trigger: every primary query failed (no default project, misnamed project,
# unreachable cloud, transient error). Why: a broken query must never error the
# session, but it must not silently look like "nothing tracked" either.
# Outcome: first-run → setup nudge; configured-but-broken → a one-line signal so
# the user can tell a typo'd/unreachable project from an empty one.
if primary_tasks is None and primary_decisions is None and primary_sessions is None:
if not configured:
print("# Basic Memory\n\n" + setup_nudge)
else:
proj = primary_project or "the default project"
print(
"# Basic Memory\n\n"
f"_Couldn't read from `{proj}` — it may be misnamed or unreachable. "
"Run `/basic-memory:bm-status` to check._"
)
sys.exit(0)
def label(result):
name = result.get("title") or result.get("file_path") or "(untitled)"
ref = result.get("permalink") or result.get("file_path") or ""
return f"- {name}" + (f" — {ref}" if ref else "")
def readable(ref):
# Qualified names ("my-team-2/notes") read fine as-is; UUIDs get shortened.
return f"shared project {ref[:8]}…" if UUID_RE.match(ref) else ref
def rows(result):
return (result or {}).get("results") or []
# --- Assemble the brief (plain stdout → Claude's context) ---
lines = ["# Basic Memory — session context", ""]
header = f"**Project:** {primary_project or 'default project'}"
if shared_refs:
header += f" · reading {len(shared_refs)} shared project(s)"
lines.append(header)
task_rows = rows(primary_tasks)
decision_rows = rows(primary_decisions)
session_rows = rows(primary_sessions)
if task_rows:
lines += ["", f"## Active tasks ({len(task_rows)})", *[label(r) for r in task_rows]]
if decision_rows:
lines += ["", f"## Open decisions ({len(decision_rows)})", *[label(r) for r in decision_rows]]
if session_rows:
lines += [
"",
f"## Recent sessions ({len(session_rows)}) — where you left off",
*[label(r) for r in session_rows],
]
if not (task_rows or decision_rows or session_rows):
lines += ["", "_No active tasks, open decisions, or recent sessions in this project._"]
# --- Shared/team context (read-only) ---
shared_sections = [(ref, rows(shared_results.get(ref))) for ref in shared_refs]
shared_sections = [(ref, items) for ref, items in shared_sections if items]
if shared_sections:
lines += ["", "## From shared projects (read-only)"]
for ref, items in shared_sections:
lines += [f"### {readable(ref)} — open decisions", *[label(r) for r in items]]
lines += [
"",
"_Shared-project context is read-only. Your captures stay in this project; "
"use `/basic-memory:bm-share` to deliberately promote a note to the team._",
]
if shared_capped:
lines += ["", f"_(reading the first {MAX_SHARED} shared projects; more are configured.)_"]
# --- Where to write (placement guidance) ---
# Trigger: a primaryProject is set (so capture is actually active — pre-compact and
# proactive writes land somewhere intentional). Why: the output style tells Claude to
# follow the project's placement conventions, but nothing else surfaces them.
# Outcome: Claude sees that session checkpoints go to captureFolder while decisions/
# tasks/notes follow the stored conventions — so it doesn't dump everything into the
# checkpoint folder. Bounded — conventions are a short string by design.
if primary_project:
# captureFolder is the PreCompact *checkpoint* folder only; proactive captures
# (decisions, tasks, notes) follow placementConventions, not this folder.
placement = [
"",
"## Where to write",
f"- Session checkpoints (the PreCompact auto-capture) go to `{capture_folder}/`.",
]
if placement_conventions:
placement.append(
"- Decisions, tasks, and other notes follow these placement "
f"conventions: {placement_conventions}"
)
else:
placement.append(
"- Place decisions, tasks, and notes in folders that fit their topic, "
"not the checkpoint folder."
)
lines += placement
# --- First-run / config nudges ---
if not configured:
lines += ["", setup_nudge]
elif not primary_project:
lines += [
"",
"_Tip: set `basicMemory.primaryProject` in `.claude/settings.json` to "
"pin this project (see the plugin's settings.example.json)._",
]
lines += ["", "---", recall_prompt]
print("\n".join(lines))
PY