forget-connect 0.3.0 → 0.4.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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # forget-connect
2
2
 
3
3
  Connect Claude Code, Codex, and Claude Desktop to a local
4
- [Forget](https://github.com/junghunkim/forget) memory server without
4
+ [Forget](https://github.com/multi-turn-inc/forget) memory server without
5
5
  overwriting the MCP servers or global instructions you already have.
6
6
 
7
7
  ```bash
@@ -96,6 +96,7 @@ def _digest(transcript_path: str) -> dict:
96
96
  "last_ts": last_ts,
97
97
  "user_snippets": user_snippets[-USER_SNIPPETS:],
98
98
  "assistant_blob": _normalize(" ".join(assistant_parts)),
99
+ "last_assistant": _normalize(assistant_parts[-1])[-240:] if assistant_parts else "",
99
100
  "tail_only": size > TAIL_BYTES,
100
101
  }
101
102
 
@@ -126,6 +127,25 @@ def _capture(hook_input: dict, digest: dict, transcript_path: str, session_id: s
126
127
  )
127
128
 
128
129
 
130
+ def _handoff(digest: dict, transcript_path: str, session_id: str) -> None:
131
+ """Shift-change note: compaction is an accident mid-sentence, not a death.
132
+
133
+ PreCompact writes what the cut hand was holding — the last human ask and
134
+ the last assistant sentence — so the post-compact SessionStart can hand
135
+ the thread back once, then burn the note. Judgment-free, fail-open.
136
+ """
137
+ os.makedirs(STATE_DIR, exist_ok=True)
138
+ note = {
139
+ "session_id": session_id,
140
+ "cut_at": digest.get("last_ts") or "",
141
+ "last_user": (digest.get("user_snippets") or [""])[-1],
142
+ "last_assistant": digest.get("last_assistant") or "",
143
+ "transcript_path": transcript_path,
144
+ }
145
+ with open(os.path.join(STATE_DIR, "handoff.json"), "w", encoding="utf-8") as fh:
146
+ json.dump(note, fh, ensure_ascii=False)
147
+
148
+
129
149
  def _outcome(digest: dict, session_id: str) -> None:
130
150
  state_path = os.path.join(STATE_DIR, f"{session_id}.json")
131
151
  if not os.path.exists(state_path):
@@ -176,6 +196,11 @@ def main() -> None:
176
196
  _capture(hook_input, digest, transcript_path, session_id)
177
197
  except Exception:
178
198
  pass
199
+ if str(hook_input.get("hook_event_name") or "") == "PreCompact":
200
+ try:
201
+ _handoff(digest, transcript_path, session_id)
202
+ except Exception:
203
+ pass
179
204
  # Outcome is a session-final label: a mid-session compact must not consume
180
205
  # the offer ledger, or usage after the compact goes unmeasured.
181
206
  if str(hook_input.get("hook_event_name") or "") == "SessionEnd":
@@ -13,11 +13,45 @@ from __future__ import annotations
13
13
  import json
14
14
  import os
15
15
  import sys
16
+ import time
16
17
  import urllib.request
17
18
 
18
19
  FORGET_URL = os.environ.get("FORGET_MCP_URL", "http://127.0.0.1:8000/mcp")
19
20
  STATE_DIR = os.path.expanduser("~/.forget/hooks/state")
20
21
  CAPSULE_CHAR_BUDGET = 1_600 # ~400 tokens
22
+ HANDOFF_MAX_AGE_SECONDS = 48 * 3600 # a stale shift-note is worse than none
23
+
24
+
25
+ def _consume_handoff() -> str:
26
+ """Read the PreCompact shift-note once, then burn it.
27
+
28
+ Delivered to exactly one next hand: the post-compact continuation
29
+ (source="compact") or, failing that, the next fresh session within 48h.
30
+ """
31
+ path = os.path.join(STATE_DIR, "handoff.json")
32
+ if not os.path.exists(path):
33
+ return ""
34
+ try:
35
+ fresh = (time.time() - os.path.getmtime(path)) <= HANDOFF_MAX_AGE_SECONDS
36
+ with open(path, encoding="utf-8") as fh:
37
+ note = json.load(fh)
38
+ except Exception:
39
+ return ""
40
+ finally:
41
+ try:
42
+ os.replace(path, path + ".done")
43
+ except OSError:
44
+ pass
45
+ if not fresh:
46
+ return ""
47
+ lines = ["[교대 인수인계 — 직전 세션이 압축으로 문장 중간에서 잘렸음]"]
48
+ if note.get("last_user"):
49
+ lines.append(f"마지막 실: {str(note['last_user'])[:200]}")
50
+ if note.get("last_assistant"):
51
+ lines.append(f"잘린 손의 마지막 문장: …{str(note['last_assistant'])[:200]}")
52
+ if note.get("transcript_path"):
53
+ lines.append(f"원문: {note['transcript_path']} (recall_episode로 열람 가능)")
54
+ return "\n".join(lines)
21
55
 
22
56
 
23
57
  def main() -> None:
@@ -49,13 +83,19 @@ def main() -> None:
49
83
  items = use_now.get("items") if isinstance(use_now, dict) else None
50
84
  if isinstance(items, list) and items:
51
85
  capsule = "\n".join(f"- {item}" for item in items[:6] if isinstance(item, str))
52
- if not capsule:
86
+ handoff = _consume_handoff()
87
+ if not capsule and not handoff:
53
88
  return # low confidence → silence
54
89
  shown = capsule[:CAPSULE_CHAR_BUDGET]
55
- print(
56
- "[forget 캡슐 — 제안이며 명령이 아님. 채택/기각은 네 판단. "
57
- "라벨 없는 항목은 노랑(행동 전 확인) 취급]\n" + shown
58
- )
90
+ parts = []
91
+ if handoff:
92
+ parts.append(handoff)
93
+ if shown:
94
+ parts.append(
95
+ "[forget 캡슐 — 제안이며 명령이 아님. 채택/기각은 네 판단. "
96
+ "라벨 없는 항목은 노랑(행동 전 확인) 취급]\n" + shown
97
+ )
98
+ print("\n".join(parts))
59
99
  # Offer ledger: record what was offered so the capture hook can measure,
60
100
  # at session end, whether the offer was actually used (outcome flywheel).
61
101
  session_id = str(hook_input.get("session_id") or "").strip()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forget-connect",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Safely connect Claude Code, Codex, and Claude Desktop to Forget memory.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,4 +35,4 @@
35
35
  "directory": "packages/forget-connect"
36
36
  },
37
37
  "homepage": "https://multi-turn.ai"
38
- }
38
+ }