kyp-mem 0.7.3 → 0.8.0

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.
package/bin/cli.mjs CHANGED
@@ -26,13 +26,25 @@ function run(command, cmdArgs, stdio = "ignore") {
26
26
  if (args[0] === "hook") {
27
27
  const hookType = args[1];
28
28
  const sessionDir = join(homedir(), ".kyp-mem", "sessions");
29
- const sessionFile = join(sessionDir, "current.jsonl");
30
29
 
31
30
  const chunks = [];
32
31
  process.stdin.on("data", (chunk) => chunks.push(chunk));
33
32
  await new Promise((r) => process.stdin.on("end", r));
34
33
  const raw = Buffer.concat(chunks).toString();
35
34
 
35
+ // Partition the activity log per Claude session id. Previously every session
36
+ // (across all projects) appended to one shared current.jsonl, so concurrent
37
+ // sessions interleaved and the Stop hook filed the whole batch under whichever
38
+ // project logged first — leaking foreign summaries into unrelated projects.
39
+ let sessionId = "";
40
+ try {
41
+ sessionId = ((JSON.parse(raw) || {}).session_id || "").toString();
42
+ } catch (_) {}
43
+ const safeId = sessionId.replace(/[^A-Za-z0-9_-]/g, "");
44
+ const sessionFile = safeId
45
+ ? join(sessionDir, `current-${safeId}.jsonl`)
46
+ : join(sessionDir, "current.jsonl");
47
+
36
48
  if (hookType === "user-prompt") {
37
49
  try {
38
50
  const data = JSON.parse(raw);
@@ -59,7 +71,7 @@ if (args[0] === "hook") {
59
71
  const input = data.tool_input || {};
60
72
  const rawResp = data.tool_response || "";
61
73
  const resp = (typeof rawResp === "string" ? rawResp : JSON.stringify(rawResp)).slice(0, 2000);
62
- const entry = { ts: new Date().toISOString(), tool, cwd: process.cwd() };
74
+ const entry = { ts: new Date().toISOString(), tool, cwd: process.env.CLAUDE_PROJECT_DIR || process.cwd() };
63
75
 
64
76
  if (tool === "Edit" || tool === "Write") {
65
77
  entry.file = input.file_path || "";
@@ -93,7 +105,7 @@ if (args[0] === "hook") {
93
105
  const py = resolvePython();
94
106
  if (py) {
95
107
  const [cmd, pre] = py;
96
- const r = run(cmd, [...pre, "-m", "kyp_mem.hooks", "stop"], "inherit");
108
+ const r = run(cmd, [...pre, "-m", "kyp_mem.hooks", "stop", safeId], "inherit");
97
109
  process.exit(r.status ?? 0);
98
110
  }
99
111
  process.exit(0);
package/kyp_mem/cli.py CHANGED
@@ -49,6 +49,10 @@ def main():
49
49
  cfg_parser.add_argument("key", nargs="?", help="Config key (e.g. session_model)")
50
50
  cfg_parser.add_argument("value", nargs="?", help="Value to set")
51
51
 
52
+ obj_parser = subparsers.add_parser("objective", help="Get or set a project's objective (injected at session start)")
53
+ obj_parser.add_argument("project", nargs="?", help="Project name (defaults to current directory name)")
54
+ obj_parser.add_argument("text", nargs="*", help="Objective text to set (omit to read the current objective)")
55
+
52
56
  hook_parser = subparsers.add_parser("hook", help="Handle Claude Code hook events (internal)")
53
57
  hook_sub = hook_parser.add_subparsers(dest="hook_command")
54
58
  hook_sub.add_parser("session-start", help="Inject project context at session start")
@@ -79,6 +83,8 @@ def main():
79
83
  _run_install_hooks(global_config=args.global_config, remove=args.remove)
80
84
  elif args.command == "config":
81
85
  _run_config(args.key, args.value)
86
+ elif args.command == "objective":
87
+ _run_objective(args.project, " ".join(args.text).strip())
82
88
  elif args.command == "uninstall":
83
89
  _run_uninstall(purge=args.purge)
84
90
  elif args.command == "doctor":
@@ -460,6 +466,39 @@ def _run_config(key, value):
460
466
  print(f" {G}✓{R} {key} = {value}")
461
467
 
462
468
 
469
+ def _run_objective(project, text):
470
+ from .config import get_vault_path
471
+ from .vault import Vault
472
+
473
+ project = project or Path.cwd().name
474
+ vault = Vault(get_vault_path())
475
+ path = f"{project}/Objective.md"
476
+
477
+ if not text:
478
+ note = vault.read(path)
479
+ print()
480
+ print(f" {C}KYP-MEM{R} — Objective for {G}{project}{R}")
481
+ print()
482
+ if not note:
483
+ print(f" {Y}(not set){R}")
484
+ print(f" {D} Set one: kyp-mem objective {project} \"<your goal>\"{R}")
485
+ else:
486
+ content = note.content.strip()
487
+ lines = content.split("\n")
488
+ if lines and lines[0].lstrip().startswith("# "):
489
+ content = "\n".join(lines[1:]).strip()
490
+ print(f" {content}")
491
+ print()
492
+ return
493
+
494
+ content = f"# Objective\n\n{text}\n"
495
+ vault.write_note(path, content, ["objective", project.lower().replace(" ", "-")], {})
496
+ print()
497
+ print(f" {G}✓{R} Objective saved for {G}{project}{R} ({path})")
498
+ print(f" {D} Injected at every session start.{R}")
499
+ print()
500
+
501
+
463
502
  def _run_stats():
464
503
  from .config import get_vault_path
465
504
  from .vault import Vault
package/kyp_mem/hooks.py CHANGED
@@ -10,6 +10,37 @@ from pathlib import Path
10
10
  SESSION_DIR = Path.home() / ".kyp-mem" / "sessions"
11
11
  CURRENT_SESSION = SESSION_DIR / "current.jsonl"
12
12
 
13
+
14
+ def _session_file(session_id):
15
+ """Per-Claude-session activity log path.
16
+
17
+ Each Claude session gets its own ``current-<session_id>.jsonl`` so that
18
+ concurrent sessions in different projects never share one file. Falls back
19
+ to the legacy shared ``current.jsonl`` when no session id is available.
20
+ """
21
+ if session_id:
22
+ safe = "".join(c for c in str(session_id) if c.isalnum() or c in "_-")
23
+ if safe:
24
+ return SESSION_DIR / f"current-{safe}.jsonl"
25
+ return CURRENT_SESSION
26
+
27
+
28
+ def _prune_stale_logs(max_age_days=3):
29
+ """Remove orphaned activity logs left by sessions that never fired Stop
30
+ (crashes, kills) plus the legacy shared current.jsonl once it goes idle."""
31
+ import time
32
+
33
+ cutoff = time.time() - max_age_days * 86400
34
+ try:
35
+ for f in SESSION_DIR.glob("current*.jsonl"):
36
+ try:
37
+ if f.stat().st_mtime < cutoff:
38
+ f.unlink(missing_ok=True)
39
+ except OSError:
40
+ pass
41
+ except Exception:
42
+ pass
43
+
13
44
  MIN_ACTIONS = 5
14
45
  CHARS_PER_TOKEN = 4
15
46
 
@@ -114,8 +145,42 @@ def _build_stats_line(project_name, injected_chars, session_ids):
114
145
  return None
115
146
 
116
147
 
148
+ def _objective_note_path(project_name):
149
+ """Canonical vault path for a project's objective note."""
150
+ return f"{project_name}/Objective.md"
151
+
152
+
153
+ def _find_objective_path(vault, project_name):
154
+ """Case-insensitive lookup of the objective note (vault casing may differ
155
+ from the cwd basename on case-insensitive filesystems)."""
156
+ target = f"{project_name}/objective.md".lower()
157
+ for p in vault.index.notes:
158
+ if p.lower() == target:
159
+ return p
160
+ return None
161
+
162
+
163
+ def _read_objective(vault, project_name):
164
+ """Return the objective text for a project, or None if not set.
165
+
166
+ Strips an optional leading ``# ...`` heading so only the objective body
167
+ is surfaced.
168
+ """
169
+ path = _find_objective_path(vault, project_name)
170
+ if not path:
171
+ return None
172
+ note = vault.read(path)
173
+ if not note:
174
+ return None
175
+ lines = note.content.strip().split("\n")
176
+ if lines and lines[0].lstrip().startswith("# "):
177
+ lines = lines[1:]
178
+ text = "\n".join(lines).strip()
179
+ return text or None
180
+
181
+
117
182
  def handle_session_start():
118
- """Inject recent session memory into the conversation at session start."""
183
+ """Inject the project objective and recent session memory at session start."""
119
184
  sys.stdin.read()
120
185
  if _is_subprocess():
121
186
  return
@@ -123,43 +188,76 @@ def handle_session_start():
123
188
  cwd = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
124
189
  project_name = Path(cwd).name
125
190
 
191
+ _prune_stale_logs()
192
+
126
193
  try:
127
194
  from .config import get_vault_path
128
195
  from .vault import Vault
129
196
 
130
197
  vault = Vault(get_vault_path())
131
198
 
132
- project_notes = [p for p in vault.index.notes if p.startswith(f"{project_name}/")]
133
- if not project_notes:
134
- return
199
+ # Match case-insensitively: the project dir may be stored in the vault
200
+ # with different casing than the cwd basename (e.g. on case-insensitive
201
+ # filesystems "KYP-MEM" and "kyp-mem" are the same directory).
202
+ prefix = f"{project_name}/".lower()
203
+ project_notes = [p for p in vault.index.notes if p.lower().startswith(prefix)]
204
+
205
+ objective = _read_objective(vault, project_name)
135
206
 
136
207
  sessions = sorted(
137
- (p for p in project_notes if "/Sessions/" in p),
208
+ (p for p in project_notes if "/sessions/" in p.lower()),
138
209
  reverse=True,
139
- )[:3]
140
- if not sessions:
141
- return
210
+ )[:10]
211
+
212
+ # Nothing to say: no objective to surface, no objective to request
213
+ # (project is already known but sessions just haven't been captured),
214
+ # and no sessions. Only stay silent when this is an established project
215
+ # with an objective but zero sessions — otherwise we always at least
216
+ # surface or request the objective.
217
+ if not objective and not project_notes and not sessions:
218
+ # Brand-new / unknown directory: still ask for an objective so the
219
+ # project starts with a clear goal.
220
+ pass
142
221
 
143
- parts = [f"# [kyp-mem] {project_name} — Recent Sessions"]
222
+ parts = [f"# [kyp-mem] {project_name} — Session Context"]
144
223
  parts.append(f"Use `kyp_search` or `kyp_project_context` for architecture/project knowledge on demand.\n")
145
224
 
146
- parts.append(f"## Last {len(sessions)} Sessions")
147
- for sp in sessions:
148
- note = vault.read(sp)
149
- if not note:
150
- continue
151
- parts.append(f"### {note.title}")
152
- summary = _extract_session_summary(note.content)
153
- parts.append(summary)
225
+ # --- Objective (always first) ---
226
+ if objective:
227
+ parts.append("## 🎯 Objective")
228
+ parts.append(objective)
154
229
  parts.append("")
230
+ else:
231
+ parts.append("## 🎯 Objective — NOT SET")
232
+ parts.append(
233
+ f"No objective is recorded for **{project_name}**. Before anything else, "
234
+ "ask the user: **\"What is the main objective / goal for this project?\"** "
235
+ "When they answer, save it by calling "
236
+ f"`kyp_objective_set(project=\"{project_name}\", objective=\"...\")` "
237
+ f"(or `kyp_write(\"{project_name}/Objective.md\", ...)`). "
238
+ "Keep your work aligned to this objective every session."
239
+ )
240
+ parts.append("")
241
+
242
+ # --- Recent sessions ---
243
+ if sessions:
244
+ parts.append(f"## Last {len(sessions)} Sessions")
245
+ for sp in sessions:
246
+ note = vault.read(sp)
247
+ if not note:
248
+ continue
249
+ parts.append(f"### {note.title}")
250
+ summary = _extract_session_summary(note.content)
251
+ parts.append(summary)
252
+ parts.append("")
155
253
 
156
- session_ids = {Path(sp).stem for sp in sessions}
157
- stats_line = _build_stats_line(project_name, len("\n".join(parts)), session_ids)
158
- if stats_line:
159
- parts.append(stats_line)
254
+ session_ids = {Path(sp).stem for sp in sessions}
255
+ stats_line = _build_stats_line(project_name, len("\n".join(parts)), session_ids)
256
+ if stats_line:
257
+ parts.append(stats_line)
160
258
 
161
259
  parts.append("")
162
- parts.append("**CRITICAL: Your FIRST response to the user MUST be displaying the session summaries below. Do NOT skip this. Do NOT wait for user input. Display them immediately, formatted cleanly, before doing anything else.**")
260
+ parts.append("**CRITICAL: Your FIRST response to the user MUST surface this context — display the session summaries above (if any) and the objective. If the objective is NOT SET, ask the user for it as instructed. Do this immediately, formatted cleanly, before anything else.**")
163
261
 
164
262
  output = "\n".join(parts)
165
263
 
@@ -193,7 +291,7 @@ def handle_user_prompt():
193
291
  }
194
292
 
195
293
  SESSION_DIR.mkdir(parents=True, exist_ok=True)
196
- with open(CURRENT_SESSION, "a") as f:
294
+ with open(_session_file(data.get("session_id", "")), "a") as f:
197
295
  f.write(json.dumps(entry) + "\n")
198
296
 
199
297
 
@@ -255,7 +353,7 @@ def handle_post_tool_use():
255
353
  return
256
354
 
257
355
  SESSION_DIR.mkdir(parents=True, exist_ok=True)
258
- with open(CURRENT_SESSION, "a") as f:
356
+ with open(_session_file(data.get("session_id", "")), "a") as f:
259
357
  f.write(json.dumps(entry) + "\n")
260
358
 
261
359
 
@@ -513,13 +611,14 @@ Raw session data:
513
611
  return None
514
612
 
515
613
 
516
- def handle_stop():
517
- if _is_subprocess() or not CURRENT_SESSION.exists():
614
+ def handle_stop(session_id=""):
615
+ session_file = _session_file(session_id)
616
+ if _is_subprocess() or not session_file.exists():
518
617
  return
519
618
 
520
- text = CURRENT_SESSION.read_text().strip()
619
+ text = session_file.read_text().strip()
521
620
  if not text:
522
- CURRENT_SESSION.unlink(missing_ok=True)
621
+ session_file.unlink(missing_ok=True)
523
622
  return
524
623
 
525
624
  entries = []
@@ -530,12 +629,12 @@ def handle_stop():
530
629
  continue
531
630
 
532
631
  if not entries:
533
- CURRENT_SESSION.unlink(missing_ok=True)
632
+ session_file.unlink(missing_ok=True)
534
633
  return
535
634
 
536
635
  write_actions = [e for e in entries if e.get("action") in ("edit", "create", "command")]
537
636
  if len(write_actions) < MIN_ACTIONS:
538
- CURRENT_SESSION.unlink(missing_ok=True)
637
+ session_file.unlink(missing_ok=True)
539
638
  return
540
639
 
541
640
  project_dir = entries[0].get("cwd", "unknown")
@@ -691,8 +790,8 @@ def handle_stop():
691
790
  pass
692
791
 
693
792
  # Delete session file BEFORE summarization so the spawned claude subprocess
694
- # doesn't pollute it via hooks writing back into current.jsonl
695
- CURRENT_SESSION.unlink(missing_ok=True)
793
+ # doesn't pollute it via hooks writing back into the session log
794
+ session_file.unlink(missing_ok=True)
696
795
 
697
796
  # Try Claude summarization, fall back to raw sections
698
797
  summarized = _summarize_with_claude(raw_note, project_name)
@@ -765,7 +864,8 @@ def handle_stop():
765
864
 
766
865
  def main():
767
866
  if len(sys.argv) > 1 and sys.argv[1] == "stop":
768
- handle_stop()
867
+ session_id = sys.argv[2] if len(sys.argv) > 2 else ""
868
+ handle_stop(session_id)
769
869
  else:
770
870
  raw = sys.stdin.read().strip()
771
871
  if not raw:
@@ -775,7 +875,7 @@ def main():
775
875
  except json.JSONDecodeError:
776
876
  return
777
877
  if "stop_reason" in data:
778
- handle_stop()
878
+ handle_stop(data.get("session_id", ""))
779
879
 
780
880
 
781
881
  if __name__ == "__main__":
package/kyp_mem/server.py CHANGED
@@ -22,6 +22,7 @@ YOU MUST FOLLOW THESE INSTRUCTIONS when kyp-mem tools are available.
22
22
  2. Call `kyp_project_context(project)` to load the project's knowledge base, notes, and recent session summaries.
23
23
  3. If no project exists yet, call `kyp_project_context` anyway — if it returns empty, ask the user if you should create one.
24
24
  4. Use the returned context to ground yourself: understand architecture, known bugs, past decisions, and what was done in recent sessions. Do NOT ask the user questions that are already answered in the project context.
25
+ 5. Check the project objective with `kyp_objective_get(project)`. If none is set, ask the user for the project's main goal and save it with `kyp_objective_set(project, objective)`. Keep your work aligned to this objective.
25
26
 
26
27
  ### DURING WORK — WHEN TO SEARCH SESSIONS
27
28
  Call `kyp_session_search(query)` when:
@@ -342,6 +343,31 @@ def kyp_sessions(project: str = "", limit: int = 10) -> str:
342
343
  return "\n".join(lines)
343
344
 
344
345
 
346
+ @mcp.tool()
347
+ def kyp_objective_get(project: str) -> str:
348
+ """Get the recorded objective / main goal for a project. The objective is injected at every session start. Returns a not-set message if none exists yet."""
349
+ note = vault.read(f"{project}/Objective.md")
350
+ if not note:
351
+ return f"No objective set for '{project}'. Ask the user for the project's main goal, then call kyp_objective_set."
352
+ content = note.content.strip()
353
+ lines = content.split("\n")
354
+ if lines and lines[0].lstrip().startswith("# "):
355
+ content = "\n".join(lines[1:]).strip()
356
+ return content or f"No objective set for '{project}'."
357
+
358
+
359
+ @mcp.tool()
360
+ def kyp_objective_set(project: str, objective: str) -> str:
361
+ """Set (or replace) the objective / main goal for a project. This is injected into every future session start so work stays aligned. Call this once the user tells you what the project is for."""
362
+ objective = objective.strip()
363
+ if not objective:
364
+ return "Objective text is empty — nothing saved."
365
+ path = f"{project}/Objective.md"
366
+ content = f"# Objective\n\n{objective}\n"
367
+ vault.write_note(path, content, ["objective", project.lower().replace(" ", "-")], {})
368
+ return f"Objective saved for '{project}' ({path}). It will be injected at every session start."
369
+
370
+
345
371
  @mcp.tool()
346
372
  def kyp_project_context(project: str) -> str:
347
373
  """CALL THIS AT SESSION START. Returns the project's full context: Knowledge.md (ground truth), project notes, and recent session summaries. Use this to understand architecture, known bugs, past decisions, and what was done recently. This prevents hallucination and avoids repeating past work."""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kyp-mem",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "description": "Know Your Project — Persistent & Session level knowledge base for AI agents. MCP-powered with wikilinks, backlinks, auto-learning, and neon web UI.",
5
5
  "bin": {
6
6
  "kyp-mem": "bin/cli.mjs"
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "kyp-mem"
7
- version = "0.7.3"
7
+ version = "0.8.0"
8
8
  description = "Know Your Project — Persistent knowledge base for AI agents. MCP-powered with wikilinks, backlinks, auto-learning, and neon web UI."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}