kyp-mem 0.5.1 → 0.6.1
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 +59 -44
- package/bin/cli.mjs +17 -0
- package/bin/install.mjs +61 -10
- package/kyp_mem/cli.py +62 -3
- package/kyp_mem/config.py +1 -1
- package/kyp_mem/hooks.py +146 -116
- package/kyp_mem/static/index.html +91 -33
- package/kyp_mem/ui.py +12 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
KYP-MEM gives AI coding agents two-layer memory:
|
|
7
7
|
|
|
8
|
-
- **Session Memory(Episodic)** → remembers what happened across coding sessions
|
|
8
|
+
- **Session Memory (Episodic)** → remembers what happened across coding sessions
|
|
9
9
|
|
|
10
10
|
- **Project Intelligence** → understands architecture, decisions, docs, and relationships
|
|
11
11
|
|
|
@@ -27,74 +27,74 @@ By intercepting the prompt, KYP-MEM automatically provided the agent with:
|
|
|
27
27
|
- The vectorized semantic search results of past session logs.
|
|
28
28
|
- The relevant markdown files from the project knowledge base.
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
## How it works
|
|
30
|
+
## How It Works
|
|
34
31
|
|
|
35
32
|
KYP-MEM operates as a Model Context Protocol (MCP) server that runs silently in the background, integrating directly with Claude Code.
|
|
36
33
|
|
|
37
|
-
|
|
34
|
+
### 1. Episodic Memory (Sessions)
|
|
38
35
|
|
|
39
|
-
|
|
36
|
+
Every coding session is automatically captured with full context:
|
|
40
37
|
|
|
41
|
-
|
|
38
|
+
- User prompts (what was asked)
|
|
39
|
+
- File reads with content (what was found)
|
|
40
|
+
- File edits with diffs (what changed and why)
|
|
41
|
+
- Command outputs (what happened)
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
- commands
|
|
45
|
-
- files changed
|
|
46
|
-
- bugs investigated
|
|
47
|
-
- decisions made
|
|
43
|
+
At session end, Claude Sonnet synthesizes raw activity into a structured summary with **Summary**, **Investigated**, **Learned**, **Completed**, and **Next Steps** sections. Sessions are semantically searchable via ChromaDB vector embeddings.
|
|
48
44
|
|
|
49
|
-
|
|
45
|
+
### 2. Project Intelligence (Vault)
|
|
50
46
|
|
|
51
|
-
|
|
47
|
+
KYP-MEM maintains structured project knowledge as Markdown files with `[[wikilinks]]`:
|
|
52
48
|
|
|
53
|
-
|
|
49
|
+
- Architecture docs, API references, setup guides
|
|
50
|
+
- Known issues, decision history, linked concepts
|
|
54
51
|
|
|
55
|
-
-
|
|
56
|
-
- APIs
|
|
57
|
-
- setup docs
|
|
58
|
-
- known issues
|
|
59
|
-
- linked concepts
|
|
60
|
-
- decision history
|
|
52
|
+
The agent searches this on-demand via `kyp_search` when it needs project context.
|
|
61
53
|
|
|
62
|
-
|
|
54
|
+
### How It All Connects
|
|
63
55
|
|
|
64
|
-
1. **
|
|
65
|
-
2. **
|
|
66
|
-
3. **
|
|
67
|
-
4. **
|
|
56
|
+
1. **Session Start:** Recent session summaries are injected automatically — the agent knows what happened last time.
|
|
57
|
+
2. **During Work:** Hooks capture tool activity (reads, edits, commands) with actual content, not just file names.
|
|
58
|
+
3. **Session End:** Sonnet synthesizes a rich, semantic summary and saves it to the vault + vector DB.
|
|
59
|
+
4. **Future Sessions:** The agent can search past sessions semantically or look up project knowledge on demand.
|
|
68
60
|
|
|
69
61
|
## Installation
|
|
70
62
|
|
|
71
63
|
```bash
|
|
72
|
-
npm
|
|
73
|
-
|
|
74
|
-
pip install kyp-mem (coming soon)
|
|
64
|
+
npm install -g kyp-mem
|
|
75
65
|
```
|
|
76
66
|
|
|
77
|
-
|
|
67
|
+
That's it. The postinstall script automatically:
|
|
78
68
|
|
|
79
|
-
|
|
69
|
+
1. Installs the Python package
|
|
70
|
+
2. Creates the default vault at `~/.kyp-mem/vault`
|
|
71
|
+
3. Registers the MCP server with Claude Code
|
|
72
|
+
4. Installs session capture hooks
|
|
80
73
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
kyp-mem install-hooks # Enable automatic session capture (Episodic Memory)
|
|
85
|
-
```
|
|
74
|
+
Restart Claude Code and you're ready to go.
|
|
75
|
+
|
|
76
|
+
### Requirements
|
|
86
77
|
|
|
87
|
-
|
|
78
|
+
- Node.js 18+
|
|
79
|
+
- Python 3.10+
|
|
80
|
+
- Claude Code CLI
|
|
81
|
+
- Anthropic API key (for session summarization with Sonnet)
|
|
88
82
|
|
|
89
|
-
|
|
83
|
+
### Custom Vault Path
|
|
84
|
+
|
|
85
|
+
If you want to store your vault somewhere other than `~/.kyp-mem/vault`:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
kyp-mem init # Interactive prompt to choose vault location
|
|
89
|
+
```
|
|
90
90
|
|
|
91
91
|
## The Agent's Workflow
|
|
92
92
|
|
|
93
|
-
KYP-MEM embeds behavioral instructions directly into its tools. Without any prompting
|
|
93
|
+
KYP-MEM embeds behavioral instructions directly into its tools. Without any prompting from you, the agent will automatically:
|
|
94
94
|
|
|
95
|
-
1. **Load Context:** On session start, it loads
|
|
96
|
-
2. **Search Before Acting:** Before investigating bugs or making
|
|
97
|
-
3. **Persist Knowledge:** After fixing a bug or making a decision, it
|
|
95
|
+
1. **Load Context:** On session start, it loads recent session summaries so it knows what happened last time.
|
|
96
|
+
2. **Search Before Acting:** Before investigating bugs or making decisions, it searches past sessions to avoid repeating work.
|
|
97
|
+
3. **Persist Knowledge:** After fixing a bug or making a decision, it updates the project's knowledge base for future sessions.
|
|
98
98
|
|
|
99
99
|
## Web UI
|
|
100
100
|
|
|
@@ -109,14 +109,29 @@ kyp-mem ui
|
|
|
109
109
|
|
|
110
110
|
| Command | Description |
|
|
111
111
|
|---------|-------------|
|
|
112
|
-
| `kyp-mem init` |
|
|
112
|
+
| `kyp-mem init` | Choose vault location (default: `~/.kyp-mem/vault`) |
|
|
113
113
|
| `kyp-mem setup-claude` | Register MCP server with Claude Code |
|
|
114
114
|
| `kyp-mem install-hooks` | Enable automatic session capture |
|
|
115
115
|
| `kyp-mem serve` | Start MCP server (stdio, used by the agent) |
|
|
116
116
|
| `kyp-mem ui` | Open the local web UI |
|
|
117
117
|
| `kyp-mem stats` | Print vault statistics |
|
|
118
118
|
| `kyp-mem tree` | Print vault file tree |
|
|
119
|
+
| `kyp-mem config` | View or set configuration (e.g. `kyp-mem config session_model`) |
|
|
119
120
|
| `kyp-mem doctor` | Check installation and configuration health |
|
|
121
|
+
| `kyp-mem uninstall` | Remove hooks and MCP server from Claude Code |
|
|
122
|
+
|
|
123
|
+
## Uninstall
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
# Remove from Claude Code (keeps your vault data)
|
|
127
|
+
kyp-mem uninstall
|
|
128
|
+
|
|
129
|
+
# Remove from Claude Code AND delete all data
|
|
130
|
+
kyp-mem uninstall --purge
|
|
131
|
+
|
|
132
|
+
# Remove the npm package
|
|
133
|
+
npm uninstall -g kyp-mem
|
|
134
|
+
```
|
|
120
135
|
|
|
121
136
|
## License
|
|
122
137
|
|
package/bin/cli.mjs
CHANGED
|
@@ -84,22 +84,29 @@ if (args[0] === "hook") {
|
|
|
84
84
|
if (tool.includes("kyp-mem") || tool.includes("kyp_mem")) process.exit(0);
|
|
85
85
|
|
|
86
86
|
const input = data.tool_input || {};
|
|
87
|
+
const rawResp = data.tool_response || "";
|
|
88
|
+
const resp = (typeof rawResp === "string" ? rawResp : JSON.stringify(rawResp)).slice(0, 2000);
|
|
87
89
|
const entry = { ts: new Date().toISOString(), tool, cwd: process.cwd() };
|
|
88
90
|
|
|
89
91
|
if (tool === "Edit" || tool === "Write") {
|
|
90
92
|
entry.file = input.file_path || "";
|
|
91
93
|
entry.action = tool === "Edit" ? "edit" : "create";
|
|
94
|
+
if (input.old_string) entry.old_string = input.old_string.slice(0, 500);
|
|
95
|
+
if (input.new_string) entry.new_string = input.new_string.slice(0, 500);
|
|
92
96
|
} else if (tool === "Read") {
|
|
93
97
|
entry.file = input.file_path || "";
|
|
94
98
|
entry.action = "read";
|
|
99
|
+
entry.content = resp;
|
|
95
100
|
} else if (tool === "Bash") {
|
|
96
101
|
entry.command = (input.command || "").slice(0, 300);
|
|
97
102
|
entry.action = "command";
|
|
103
|
+
entry.output = resp;
|
|
98
104
|
} else {
|
|
99
105
|
entry.action = "other";
|
|
100
106
|
entry.detail = tool;
|
|
101
107
|
}
|
|
102
108
|
|
|
109
|
+
entry.response_chars = (typeof rawResp === "string" ? rawResp : JSON.stringify(rawResp)).length;
|
|
103
110
|
mkdirSync(sessionDir, { recursive: true });
|
|
104
111
|
appendFileSync(sessionFile, JSON.stringify(entry) + "\n");
|
|
105
112
|
} catch (_) {
|
|
@@ -118,6 +125,16 @@ if (args[0] === "hook") {
|
|
|
118
125
|
process.exit(0);
|
|
119
126
|
}
|
|
120
127
|
|
|
128
|
+
if (hookType === "session-start") {
|
|
129
|
+
const py = findPython();
|
|
130
|
+
if (py) {
|
|
131
|
+
const [cmd, pre] = py;
|
|
132
|
+
const r = run(cmd, [...pre, "-m", "kyp_mem.cli", "hook", "session-start"], "inherit");
|
|
133
|
+
process.exit(r.status ?? 0);
|
|
134
|
+
}
|
|
135
|
+
process.exit(0);
|
|
136
|
+
}
|
|
137
|
+
|
|
121
138
|
console.error("Unknown hook type:", hookType);
|
|
122
139
|
process.exit(1);
|
|
123
140
|
}
|
package/bin/install.mjs
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { spawnSync } from "child_process";
|
|
4
|
+
import { mkdirSync } from "fs";
|
|
5
|
+
import { homedir } from "os";
|
|
4
6
|
import { fileURLToPath } from "url";
|
|
5
|
-
import { dirname, resolve } from "path";
|
|
7
|
+
import { dirname, join, resolve } from "path";
|
|
6
8
|
|
|
7
9
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
10
|
const root = resolve(__dirname, "..");
|
|
9
11
|
|
|
12
|
+
const G = "\x1b[32m";
|
|
13
|
+
const Y = "\x1b[33m";
|
|
14
|
+
const C = "\x1b[36m";
|
|
15
|
+
const D = "\x1b[90m";
|
|
16
|
+
const R = "\x1b[0m";
|
|
17
|
+
|
|
10
18
|
function run(command, args, options = {}) {
|
|
11
19
|
return spawnSync(command, args, {
|
|
12
20
|
cwd: root,
|
|
@@ -53,25 +61,68 @@ if (process.env.KYP_MEM_SKIP_PYTHON_INSTALL === "1") {
|
|
|
53
61
|
const python = findPython();
|
|
54
62
|
|
|
55
63
|
if (!python) {
|
|
56
|
-
console.log(
|
|
57
|
-
console.log(
|
|
64
|
+
console.log(` ${Y}!${R} Python 3 was not found.`);
|
|
65
|
+
console.log(` ${Y}!${R} Install Python 3.10+ and run: python3 -m pip install --user .`);
|
|
58
66
|
process.exit(0);
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
const [pythonCommand, pythonPrefixArgs] = python;
|
|
62
70
|
|
|
63
|
-
|
|
71
|
+
// Step 1: Install Python package
|
|
72
|
+
console.log(` Installing kyp-mem Python package...`);
|
|
64
73
|
|
|
65
|
-
const
|
|
74
|
+
const pipResult = run(
|
|
66
75
|
pythonCommand,
|
|
67
76
|
[...pythonPrefixArgs, "-m", "pip", "install", "--user", "."],
|
|
68
77
|
{ stdio: "inherit" },
|
|
69
78
|
);
|
|
70
79
|
|
|
71
|
-
if (
|
|
72
|
-
console.log(
|
|
73
|
-
}
|
|
74
|
-
console.log(" \x1b[33m!\x1b[0m Could not auto-install the Python package.");
|
|
75
|
-
console.log(` \x1b[33m!\x1b[0m Run manually from ${root}:`);
|
|
80
|
+
if (pipResult.status !== 0) {
|
|
81
|
+
console.log(` ${Y}!${R} Could not auto-install the Python package.`);
|
|
82
|
+
console.log(` ${Y}!${R} Run manually from ${root}:`);
|
|
76
83
|
console.log(" python3 -m pip install --user .");
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.log(` ${G}✓${R} Python package installed`);
|
|
88
|
+
|
|
89
|
+
// Step 2: Create default vault directory
|
|
90
|
+
const vaultDir = join(homedir(), ".kyp-mem", "vault");
|
|
91
|
+
try {
|
|
92
|
+
mkdirSync(vaultDir, { recursive: true });
|
|
93
|
+
console.log(` ${G}✓${R} Vault ready at ${D}${vaultDir}${R}`);
|
|
94
|
+
} catch (_) {
|
|
95
|
+
console.log(` ${Y}!${R} Could not create vault at ${vaultDir}`);
|
|
77
96
|
}
|
|
97
|
+
|
|
98
|
+
// Step 3: Register MCP server with Claude Code (global)
|
|
99
|
+
const setupResult = run(
|
|
100
|
+
pythonCommand,
|
|
101
|
+
[...pythonPrefixArgs, "-m", "kyp_mem.cli", "setup-claude", "--global"],
|
|
102
|
+
{ stdio: "inherit" },
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (setupResult.status === 0) {
|
|
106
|
+
console.log(` ${G}✓${R} MCP server registered with Claude Code`);
|
|
107
|
+
} else {
|
|
108
|
+
console.log(` ${Y}!${R} Could not register MCP server — run manually: ${C}kyp-mem setup-claude --global${R}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Step 4: Install hooks (global)
|
|
112
|
+
const hooksResult = run(
|
|
113
|
+
pythonCommand,
|
|
114
|
+
[...pythonPrefixArgs, "-m", "kyp_mem.cli", "install-hooks", "--global"],
|
|
115
|
+
{ stdio: "inherit" },
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
if (hooksResult.status === 0) {
|
|
119
|
+
console.log(` ${G}✓${R} Session capture hooks installed`);
|
|
120
|
+
} else {
|
|
121
|
+
console.log(` ${Y}!${R} Could not install hooks — run manually: ${C}kyp-mem install-hooks --global${R}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log();
|
|
125
|
+
console.log(` ${C}KYP-MEM${R} is ready! Restart Claude Code to activate.`);
|
|
126
|
+
console.log(` ${D}Vault: ${vaultDir}${R}`);
|
|
127
|
+
console.log(` ${D}To customize vault path: kyp-mem init${R}`);
|
|
128
|
+
console.log();
|
package/kyp_mem/cli.py
CHANGED
|
@@ -41,6 +41,8 @@ def main():
|
|
|
41
41
|
help="Add hooks to global ~/.claude/settings.json (default: project)")
|
|
42
42
|
ih.add_argument("--remove", action="store_true", help="Remove KYP-MEM hooks")
|
|
43
43
|
|
|
44
|
+
un = subparsers.add_parser("uninstall", help="Remove KYP-MEM from Claude Code (hooks + MCP server)")
|
|
45
|
+
un.add_argument("--purge", action="store_true", help="Also delete vault data and config at ~/.kyp-mem")
|
|
44
46
|
subparsers.add_parser("doctor", help="Check installation and config health")
|
|
45
47
|
|
|
46
48
|
cfg_parser = subparsers.add_parser("config", help="Get or set configuration values")
|
|
@@ -77,6 +79,8 @@ def main():
|
|
|
77
79
|
_run_install_hooks(global_config=args.global_config, remove=args.remove)
|
|
78
80
|
elif args.command == "config":
|
|
79
81
|
_run_config(args.key, args.value)
|
|
82
|
+
elif args.command == "uninstall":
|
|
83
|
+
_run_uninstall(purge=args.purge)
|
|
80
84
|
elif args.command == "doctor":
|
|
81
85
|
_run_doctor()
|
|
82
86
|
elif args.command == "hook":
|
|
@@ -129,8 +133,24 @@ def _run_init():
|
|
|
129
133
|
print(f" {G}✓{R} Vault: {vault_path}")
|
|
130
134
|
print(f" {G}✓{R} Config: {CONFIG_FILE}")
|
|
131
135
|
print()
|
|
132
|
-
|
|
133
|
-
|
|
136
|
+
|
|
137
|
+
claude_settings = Path.home() / ".claude" / "settings.json"
|
|
138
|
+
already_setup = False
|
|
139
|
+
if claude_settings.exists():
|
|
140
|
+
try:
|
|
141
|
+
cs = json.loads(claude_settings.read_text())
|
|
142
|
+
has_mcp = "kyp-mem" in cs.get("mcpServers", {})
|
|
143
|
+
has_hooks = any("kyp-mem" in str(h) for h in cs.get("hooks", {}).get("Stop", []))
|
|
144
|
+
already_setup = has_mcp and has_hooks
|
|
145
|
+
except (json.JSONDecodeError, KeyError):
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
if already_setup:
|
|
149
|
+
print(f" {G}✓{R} Claude Code already configured (MCP server + hooks)")
|
|
150
|
+
print(f" {D} Restart Claude Code if you changed the vault path.{R}")
|
|
151
|
+
else:
|
|
152
|
+
print(f" {Y}Next step:{R} Connect to Claude Code:")
|
|
153
|
+
print(f" {Y}kyp-mem setup-claude --global && kyp-mem install-hooks --global{R}")
|
|
134
154
|
print()
|
|
135
155
|
|
|
136
156
|
|
|
@@ -267,7 +287,46 @@ def _write_legacy_claude_settings(
|
|
|
267
287
|
return settings_path
|
|
268
288
|
|
|
269
289
|
|
|
270
|
-
def
|
|
290
|
+
def _run_uninstall(purge: bool = False):
|
|
291
|
+
from .config import CONFIG_DIR
|
|
292
|
+
|
|
293
|
+
print()
|
|
294
|
+
print(f" {C}KYP-MEM{R} — Uninstall")
|
|
295
|
+
print()
|
|
296
|
+
|
|
297
|
+
# Remove hooks from global settings
|
|
298
|
+
_run_install_hooks(global_config=True, remove=True)
|
|
299
|
+
|
|
300
|
+
# Remove MCP server from Claude Code
|
|
301
|
+
claude_bin = shutil.which("claude")
|
|
302
|
+
if claude_bin:
|
|
303
|
+
subprocess.run(
|
|
304
|
+
[claude_bin, "mcp", "remove", "-s", "user", "kyp-mem"],
|
|
305
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, text=True,
|
|
306
|
+
)
|
|
307
|
+
print(f" {G}✓{R} MCP server removed from Claude Code")
|
|
308
|
+
else:
|
|
309
|
+
print(f" {Y}!{R} 'claude' CLI not found — remove kyp-mem MCP server manually")
|
|
310
|
+
|
|
311
|
+
if purge:
|
|
312
|
+
import shutil as sh
|
|
313
|
+
if CONFIG_DIR.exists():
|
|
314
|
+
sh.rmtree(CONFIG_DIR)
|
|
315
|
+
print(f" {G}✓{R} Deleted {CONFIG_DIR} (vault, config, sessions)")
|
|
316
|
+
else:
|
|
317
|
+
print(f" {D} {CONFIG_DIR} does not exist{R}")
|
|
318
|
+
|
|
319
|
+
print()
|
|
320
|
+
print(f" To finish, remove the npm package:")
|
|
321
|
+
print(f" {Y}npm uninstall -g kyp-mem{R}")
|
|
322
|
+
print()
|
|
323
|
+
if not purge:
|
|
324
|
+
print(f" {D}Your vault data at {CONFIG_DIR} was kept.{R}")
|
|
325
|
+
print(f" {D}To delete it too: kyp-mem uninstall --purge{R}")
|
|
326
|
+
print()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
|
|
271
330
|
mcp_command, _ = _get_mcp_command()
|
|
272
331
|
|
|
273
332
|
if global_config:
|
package/kyp_mem/config.py
CHANGED
package/kyp_mem/hooks.py
CHANGED
|
@@ -9,7 +9,7 @@ from pathlib import Path
|
|
|
9
9
|
SESSION_DIR = Path.home() / ".kyp-mem" / "sessions"
|
|
10
10
|
CURRENT_SESSION = SESSION_DIR / "current.jsonl"
|
|
11
11
|
|
|
12
|
-
MIN_ACTIONS =
|
|
12
|
+
MIN_ACTIONS = 5
|
|
13
13
|
CHARS_PER_TOKEN = 4
|
|
14
14
|
|
|
15
15
|
COMMAND_OUTPUT_ESTIMATES = {
|
|
@@ -74,7 +74,7 @@ def _record_injection(project, chars):
|
|
|
74
74
|
|
|
75
75
|
|
|
76
76
|
def handle_session_start():
|
|
77
|
-
"""Inject
|
|
77
|
+
"""Inject recent session memory into the conversation at session start."""
|
|
78
78
|
sys.stdin.read()
|
|
79
79
|
|
|
80
80
|
cwd = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
|
|
@@ -90,58 +90,31 @@ def handle_session_start():
|
|
|
90
90
|
if not project_notes:
|
|
91
91
|
return
|
|
92
92
|
|
|
93
|
-
parts = [f"# [kyp-mem] {project_name} — Project Context"]
|
|
94
|
-
parts.append(f"Vault: {get_vault_path()}")
|
|
95
|
-
parts.append("")
|
|
96
|
-
|
|
97
|
-
knowledge_path = f"{project_name}/Knowledge.md"
|
|
98
|
-
knowledge = vault.read(knowledge_path)
|
|
99
|
-
if knowledge:
|
|
100
|
-
parts.append("## Knowledge")
|
|
101
|
-
content = knowledge.content
|
|
102
|
-
timeline_idx = content.find("## Timeline")
|
|
103
|
-
if timeline_idx > 0:
|
|
104
|
-
content = content[:timeline_idx].strip()
|
|
105
|
-
if len(content) > 2000:
|
|
106
|
-
parts.append(content[:2000] + "\n...")
|
|
107
|
-
else:
|
|
108
|
-
parts.append(content)
|
|
109
|
-
parts.append("")
|
|
110
|
-
|
|
111
|
-
other_notes = sorted(
|
|
112
|
-
p for p in project_notes
|
|
113
|
-
if "/Sessions/" not in p and p != knowledge_path
|
|
114
|
-
)
|
|
115
|
-
if other_notes:
|
|
116
|
-
parts.append("## Project Notes")
|
|
117
|
-
for p in other_notes:
|
|
118
|
-
note = vault.index.notes.get(p)
|
|
119
|
-
title = note.title if note else p
|
|
120
|
-
tags = f" [{', '.join(note.tags)}]" if note and note.tags else ""
|
|
121
|
-
parts.append(f"- {title} ({p}){tags}")
|
|
122
|
-
parts.append("")
|
|
123
|
-
|
|
124
93
|
sessions = sorted(
|
|
125
94
|
(p for p in project_notes if "/Sessions/" in p),
|
|
126
95
|
reverse=True,
|
|
127
96
|
)[:3]
|
|
128
|
-
if sessions:
|
|
129
|
-
|
|
130
|
-
for sp in sessions:
|
|
131
|
-
note = vault.read(sp)
|
|
132
|
-
if not note:
|
|
133
|
-
continue
|
|
134
|
-
parts.append(f"### {note.title}")
|
|
135
|
-
content = note.content
|
|
136
|
-
timeline_idx = content.find("## Timeline")
|
|
137
|
-
if timeline_idx > 0:
|
|
138
|
-
content = content[:timeline_idx].strip()
|
|
139
|
-
if len(content) > 300:
|
|
140
|
-
content = content[:300] + "..."
|
|
141
|
-
parts.append(content)
|
|
142
|
-
parts.append("")
|
|
97
|
+
if not sessions:
|
|
98
|
+
return
|
|
143
99
|
|
|
144
|
-
parts
|
|
100
|
+
parts = [f"# [kyp-mem] {project_name} — Recent Sessions"]
|
|
101
|
+
parts.append(f"Use `kyp_search` or `kyp_project_context` for architecture/project knowledge on demand.")
|
|
102
|
+
parts.append("")
|
|
103
|
+
|
|
104
|
+
parts.append(f"## Last {len(sessions)} Sessions")
|
|
105
|
+
for sp in sessions:
|
|
106
|
+
note = vault.read(sp)
|
|
107
|
+
if not note:
|
|
108
|
+
continue
|
|
109
|
+
parts.append(f"### {note.title}")
|
|
110
|
+
content = note.content
|
|
111
|
+
timeline_idx = content.find("## TIMELINE")
|
|
112
|
+
if timeline_idx < 0:
|
|
113
|
+
timeline_idx = content.find("## Timeline")
|
|
114
|
+
if timeline_idx > 0:
|
|
115
|
+
content = content[:timeline_idx].strip()
|
|
116
|
+
parts.append(content)
|
|
117
|
+
parts.append("")
|
|
145
118
|
|
|
146
119
|
output = "\n".join(parts)
|
|
147
120
|
try:
|
|
@@ -195,13 +168,25 @@ def handle_post_tool_use():
|
|
|
195
168
|
cwd = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
|
|
196
169
|
entry["cwd"] = cwd
|
|
197
170
|
|
|
198
|
-
# Measure response size for token economics
|
|
199
171
|
tool_response = data.get("tool_response", "")
|
|
200
|
-
|
|
172
|
+
if isinstance(tool_response, str):
|
|
173
|
+
resp_str = tool_response
|
|
174
|
+
elif tool_response:
|
|
175
|
+
resp_str = json.dumps(tool_response)
|
|
176
|
+
else:
|
|
177
|
+
resp_str = ""
|
|
178
|
+
response_chars = len(resp_str)
|
|
179
|
+
resp_truncated = resp_str[:2000]
|
|
201
180
|
|
|
202
181
|
if tool_name == "Edit":
|
|
203
182
|
entry["action"] = "edit"
|
|
204
183
|
entry["file"] = tool_input.get("file_path", "")
|
|
184
|
+
old_s = tool_input.get("old_string", "")
|
|
185
|
+
new_s = tool_input.get("new_string", "")
|
|
186
|
+
if old_s:
|
|
187
|
+
entry["old_string"] = old_s[:500]
|
|
188
|
+
if new_s:
|
|
189
|
+
entry["new_string"] = new_s[:500]
|
|
205
190
|
elif tool_name == "Write":
|
|
206
191
|
entry["action"] = "create"
|
|
207
192
|
entry["file"] = tool_input.get("file_path", "")
|
|
@@ -214,10 +199,12 @@ def handle_post_tool_use():
|
|
|
214
199
|
except OSError:
|
|
215
200
|
pass
|
|
216
201
|
entry["response_chars"] = response_chars
|
|
202
|
+
entry["content"] = resp_truncated
|
|
217
203
|
elif tool_name == "Bash":
|
|
218
204
|
entry["action"] = "command"
|
|
219
205
|
entry["command"] = tool_input.get("command", "")
|
|
220
206
|
entry["response_chars"] = response_chars
|
|
207
|
+
entry["output"] = resp_truncated
|
|
221
208
|
else:
|
|
222
209
|
return
|
|
223
210
|
|
|
@@ -418,53 +405,62 @@ def _build_next_steps(files_edited, files_created, commands_classified):
|
|
|
418
405
|
|
|
419
406
|
|
|
420
407
|
def _summarize_with_claude(raw_note, project_name):
|
|
421
|
-
"""Use Claude to rewrite session sections
|
|
408
|
+
"""Use Claude CLI to rewrite session sections — uses existing Claude Code auth."""
|
|
422
409
|
try:
|
|
423
|
-
|
|
424
|
-
|
|
410
|
+
import shutil
|
|
411
|
+
claude_bin = shutil.which("claude")
|
|
412
|
+
if not claude_bin:
|
|
413
|
+
return None
|
|
425
414
|
|
|
415
|
+
from .config import get_session_model
|
|
426
416
|
model = get_session_model()
|
|
427
|
-
client = anthropic.Anthropic()
|
|
428
417
|
|
|
429
|
-
prompt = f"""
|
|
418
|
+
prompt = f"""Rewrite this raw coding session into a structured summary. A future AI agent reads this to pick up where you left off — be precise and technical.
|
|
419
|
+
|
|
420
|
+
You have: user prompts (the objectives), a timeline of file edits/reads/commands with their actual content and output. Synthesize into a dense, specific narrative.
|
|
430
421
|
|
|
431
|
-
|
|
422
|
+
## Format rules
|
|
432
423
|
|
|
433
|
-
|
|
434
|
-
-
|
|
435
|
-
-
|
|
436
|
-
-
|
|
437
|
-
-
|
|
438
|
-
- NEXT STEPS: What should happen next session. Infer from context — unfinished work, unfixed bugs, natural follow-ups. Max 3 bullets.
|
|
424
|
+
- **Summary**: 1-2 sentences. State what was done and the outcome. Include error messages, feature names, or bug descriptions verbatim. Example: 'Debugged and fixed "Unknown hook type: session-start" error in kyp-mem; cleaned repository of session-specific files and prepared for release'
|
|
425
|
+
- **INVESTIGATED**: One dense paragraph (not bullets). List specific files, paths, and systems examined with semicolons. Include full relative paths and module names. Example: 'Global and project-level Claude Code settings.json; kyp-mem Python CLI source (cli.py, hooks.py); installed Node.js wrapper at /opt/homebrew/lib/node_modules/kyp-mem/bin/cli.mjs; hook dispatcher implementation; git commit history'
|
|
426
|
+
- **LEARNED**: One dense paragraph (not bullets). State technical insights with specifics — what was discovered, why it matters, root causes. Include version numbers, commit hashes, config values, error messages. Example: 'kyp-mem uses a Node.js wrapper with a "hook fast path" dispatcher that only handled 3 hook types (user-prompt, post-tool-use, stop); session-start was missing despite being implemented in Python backend'
|
|
427
|
+
- **COMPLETED**: One dense paragraph (not bullets). List concrete deliverables with specifics — file names modified, features added, tests passed, counts, commit hashes. Use semicolons to separate items. Example: 'Fixed .gitignore to exclude session-specific files (CLAUDE.md, PLAN-ui-rewrite.md, templates/); removed 3 tracked files from git history; committed cleanup to main (commit f0b114e: 4 files changed, 626 deletions)'
|
|
428
|
+
- **NEXT STEPS**: One dense paragraph (not bullets). Concrete actionable items for the next session. Example: 'Push commit f0b114e to GitHub; publish 0.5.1 release to npm with session-start hook support'
|
|
439
429
|
|
|
440
|
-
|
|
430
|
+
## Critical rules
|
|
431
|
+
- ALWAYS include specific file names, paths, commit hashes, error messages, and counts
|
|
432
|
+
- Write dense paragraphs with semicolons, NOT bullet lists
|
|
433
|
+
- Never be vague: "Fixed 3 files" is bad, "Fixed .gitignore, cli.mjs, and hooks.py" is good
|
|
434
|
+
- If a commit hash appears in the timeline, include it
|
|
435
|
+
- Keep each section to one paragraph max
|
|
441
436
|
|
|
442
437
|
Return ONLY this format (no preamble):
|
|
443
438
|
|
|
444
439
|
## Summary
|
|
445
|
-
<
|
|
440
|
+
<1-2 sentences>
|
|
446
441
|
|
|
447
442
|
## INVESTIGATED
|
|
448
|
-
|
|
443
|
+
<one paragraph>
|
|
449
444
|
|
|
450
445
|
## LEARNED
|
|
451
|
-
|
|
446
|
+
<one paragraph>
|
|
452
447
|
|
|
453
448
|
## COMPLETED
|
|
454
|
-
|
|
449
|
+
<one paragraph>
|
|
455
450
|
|
|
456
451
|
## NEXT STEPS
|
|
457
|
-
|
|
452
|
+
<one paragraph>
|
|
458
453
|
|
|
459
454
|
Raw session data:
|
|
460
455
|
{raw_note}"""
|
|
461
456
|
|
|
462
|
-
|
|
463
|
-
model
|
|
464
|
-
|
|
465
|
-
messages=[{"role": "user", "content": prompt}],
|
|
457
|
+
result = subprocess.run(
|
|
458
|
+
[claude_bin, "-p", prompt, "--max-turns", "1", "--model", model],
|
|
459
|
+
capture_output=True, text=True, timeout=120,
|
|
466
460
|
)
|
|
467
|
-
|
|
461
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
462
|
+
return result.stdout.strip()
|
|
463
|
+
return None
|
|
468
464
|
except Exception:
|
|
469
465
|
return None
|
|
470
466
|
|
|
@@ -503,7 +499,7 @@ def handle_stop():
|
|
|
503
499
|
files_created = set()
|
|
504
500
|
commands = []
|
|
505
501
|
prompts = []
|
|
506
|
-
|
|
502
|
+
events = []
|
|
507
503
|
|
|
508
504
|
for e in entries:
|
|
509
505
|
ts_raw = e.get("ts", "")
|
|
@@ -512,69 +508,96 @@ def handle_stop():
|
|
|
512
508
|
|
|
513
509
|
if action == "prompt":
|
|
514
510
|
prompts.append({"ts": ts, "text": e.get("prompt", "")})
|
|
515
|
-
|
|
511
|
+
events.append({"ts": ts, "type": "prompt", "text": e.get("prompt", "")[:500]})
|
|
516
512
|
elif action == "read":
|
|
517
513
|
fp = e.get("file", "")
|
|
518
514
|
files_read.add(fp)
|
|
519
|
-
|
|
515
|
+
content = e.get("content", "")
|
|
516
|
+
events.append({"ts": ts, "type": "read", "file": fp, "content": content[:1000] if content else ""})
|
|
520
517
|
elif action == "edit":
|
|
521
518
|
fp = e.get("file", "")
|
|
522
519
|
files_edited.add(fp)
|
|
523
|
-
|
|
520
|
+
events.append({
|
|
521
|
+
"ts": ts, "type": "edit", "file": fp,
|
|
522
|
+
"old": e.get("old_string", "")[:300],
|
|
523
|
+
"new": e.get("new_string", "")[:300],
|
|
524
|
+
})
|
|
524
525
|
elif action == "create":
|
|
525
526
|
fp = e.get("file", "")
|
|
526
527
|
files_created.add(fp)
|
|
527
|
-
|
|
528
|
+
events.append({"ts": ts, "type": "create", "file": fp})
|
|
528
529
|
elif action == "command":
|
|
529
530
|
cmd = e.get("command", "")
|
|
531
|
+
output = e.get("output", "")
|
|
530
532
|
commands.append(cmd)
|
|
531
|
-
|
|
532
|
-
timeline.append(f" {ts} — `{short}`")
|
|
533
|
+
events.append({"ts": ts, "type": "command", "cmd": cmd[:300], "output": output[:1000] if output else ""})
|
|
533
534
|
|
|
534
535
|
commands_classified = [_classify_command(cmd) for cmd in commands]
|
|
535
536
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
summary_items.append(f"Modified {len(files_edited)} file{'s' if len(files_edited) != 1 else ''}")
|
|
539
|
-
if files_created:
|
|
540
|
-
summary_items.append(f"Created {len(files_created)} file{'s' if len(files_created) != 1 else ''}")
|
|
541
|
-
if commands:
|
|
542
|
-
summary_items.append(f"Ran {len(commands)} command{'s' if len(commands) != 1 else ''}")
|
|
537
|
+
# Build rich context for Sonnet — actual content, not just filenames
|
|
538
|
+
raw_parts = []
|
|
543
539
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
540
|
+
if prompts:
|
|
541
|
+
raw_parts.append("## USER PROMPTS (the objectives)")
|
|
542
|
+
for p in prompts:
|
|
543
|
+
raw_parts.append(f"[{p['ts']}] {p['text'][:500]}")
|
|
544
|
+
raw_parts.append("")
|
|
545
|
+
|
|
546
|
+
raw_parts.append("## SESSION EVENTS (chronological, with content)")
|
|
547
|
+
for ev in events:
|
|
548
|
+
if ev["type"] == "prompt":
|
|
549
|
+
raw_parts.append(f"\n### [{ev['ts']}] User asked:")
|
|
550
|
+
raw_parts.append(ev["text"])
|
|
551
|
+
elif ev["type"] == "read":
|
|
552
|
+
raw_parts.append(f"\n### [{ev['ts']}] Read `{ev['file']}`")
|
|
553
|
+
if ev.get("content"):
|
|
554
|
+
raw_parts.append(f"```\n{ev['content']}\n```")
|
|
555
|
+
elif ev["type"] == "edit":
|
|
556
|
+
raw_parts.append(f"\n### [{ev['ts']}] Edited `{ev['file']}`")
|
|
557
|
+
if ev.get("old"):
|
|
558
|
+
raw_parts.append(f"Replaced:\n```\n{ev['old']}\n```")
|
|
559
|
+
if ev.get("new"):
|
|
560
|
+
raw_parts.append(f"With:\n```\n{ev['new']}\n```")
|
|
561
|
+
elif ev["type"] == "create":
|
|
562
|
+
raw_parts.append(f"\n### [{ev['ts']}] Created `{ev['file']}`")
|
|
563
|
+
elif ev["type"] == "command":
|
|
564
|
+
raw_parts.append(f"\n### [{ev['ts']}] Ran: `{ev['cmd']}`")
|
|
565
|
+
if ev.get("output"):
|
|
566
|
+
raw_parts.append(f"Output:\n```\n{ev['output']}\n```")
|
|
548
567
|
|
|
549
|
-
# Build raw note for Claude summarization
|
|
550
|
-
raw_parts = []
|
|
551
|
-
raw_parts.append("## Summary")
|
|
552
|
-
raw_parts.append(", ".join(summary_items) + f" in `{project_name}`." if summary_items else "")
|
|
553
|
-
raw_parts.append("")
|
|
554
|
-
raw_parts.append("## INVESTIGATED")
|
|
555
|
-
if investigated:
|
|
556
|
-
raw_parts.extend(investigated)
|
|
557
568
|
raw_parts.append("")
|
|
558
|
-
raw_parts.append("##
|
|
559
|
-
|
|
560
|
-
raw_parts.
|
|
569
|
+
raw_parts.append("## FILES MODIFIED")
|
|
570
|
+
for fp in sorted(files_edited):
|
|
571
|
+
raw_parts.append(f"- {_relative_path(fp, project_dir)}")
|
|
561
572
|
raw_parts.append("")
|
|
562
|
-
raw_parts.append("##
|
|
563
|
-
|
|
564
|
-
raw_parts.
|
|
573
|
+
raw_parts.append("## FILES CREATED")
|
|
574
|
+
for fp in sorted(files_created):
|
|
575
|
+
raw_parts.append(f"- {_relative_path(fp, project_dir)}")
|
|
565
576
|
raw_parts.append("")
|
|
566
|
-
raw_parts.append("##
|
|
567
|
-
|
|
568
|
-
raw_parts.
|
|
577
|
+
raw_parts.append("## FILES READ")
|
|
578
|
+
for fp in sorted(files_read):
|
|
579
|
+
raw_parts.append(f"- {_relative_path(fp, project_dir)}")
|
|
569
580
|
|
|
570
|
-
#
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
581
|
+
# Keep timeline for backward compat in case summarization fails
|
|
582
|
+
timeline = []
|
|
583
|
+
for ev in events:
|
|
584
|
+
if ev["type"] == "prompt":
|
|
585
|
+
timeline.append(f" {ev['ts']} — Prompt: {ev['text'][:60]}...")
|
|
586
|
+
elif ev["type"] == "read":
|
|
587
|
+
timeline.append(f" {ev['ts']} — Read `{Path(ev['file']).name}`")
|
|
588
|
+
elif ev["type"] == "edit":
|
|
589
|
+
timeline.append(f" {ev['ts']} — Edit `{Path(ev['file']).name}`")
|
|
590
|
+
elif ev["type"] == "create":
|
|
591
|
+
timeline.append(f" {ev['ts']} — Write `{Path(ev['file']).name}`")
|
|
592
|
+
elif ev["type"] == "command":
|
|
593
|
+
short = ev["cmd"][:80] + "..." if len(ev["cmd"]) > 80 else ev["cmd"]
|
|
594
|
+
timeline.append(f" {ev['ts']} — `{short}`")
|
|
595
|
+
|
|
596
|
+
investigated = _build_investigated(files_read, commands_classified, project_dir)
|
|
597
|
+
learned = _build_learned(files_read, files_edited, files_created, commands_classified, project_dir)
|
|
598
|
+
completed = _build_completed(files_edited, files_created, commands_classified, project_dir)
|
|
599
|
+
next_steps = _build_next_steps(files_edited, files_created, commands_classified)
|
|
576
600
|
|
|
577
|
-
# Full timeline gives Claude the narrative arc
|
|
578
601
|
if timeline:
|
|
579
602
|
raw_parts.append("")
|
|
580
603
|
raw_parts.append("## TIMELINE (what happened, chronological)")
|
|
@@ -639,6 +662,13 @@ def handle_stop():
|
|
|
639
662
|
parts.append("")
|
|
640
663
|
parts.append(summarized)
|
|
641
664
|
else:
|
|
665
|
+
summary_items = []
|
|
666
|
+
if files_edited:
|
|
667
|
+
summary_items.append(f"Modified {len(files_edited)} file{'s' if len(files_edited) != 1 else ''}")
|
|
668
|
+
if files_created:
|
|
669
|
+
summary_items.append(f"Created {len(files_created)} file{'s' if len(files_created) != 1 else ''}")
|
|
670
|
+
if commands:
|
|
671
|
+
summary_items.append(f"Ran {len(commands)} command{'s' if len(commands) != 1 else ''}")
|
|
642
672
|
parts.append("## Summary")
|
|
643
673
|
parts.append(", ".join(summary_items) + f" in `{project_name}`." if summary_items else "")
|
|
644
674
|
parts.append("")
|
|
@@ -475,8 +475,9 @@ body.resizing #resize-handle { pointer-events: auto !important; }
|
|
|
475
475
|
.graph-header {
|
|
476
476
|
display: flex; align-items: center; justify-content: space-between;
|
|
477
477
|
padding: 10px 14px; border-bottom: 1px solid var(--line);
|
|
478
|
-
background: var(--bg-2); gap: 12px;
|
|
478
|
+
background: var(--bg-2); gap: 12px; position: relative; z-index: 10;
|
|
479
479
|
}
|
|
480
|
+
.graph-header .ghost-btn { cursor: pointer; }
|
|
480
481
|
.graph-header-left { display: flex; align-items: center; gap: 10px; font-size: var(--fz-sm); white-space: nowrap; }
|
|
481
482
|
.graph-header-right { display: flex; gap: 6px; flex-shrink: 0; }
|
|
482
483
|
.tool-chip {
|
|
@@ -1428,6 +1429,8 @@ function renderSessionView(note) {
|
|
|
1428
1429
|
}
|
|
1429
1430
|
|
|
1430
1431
|
// ─── Graph View ──────────────────────────────────────────────────────────────
|
|
1432
|
+
let _graphMode = 'projects';
|
|
1433
|
+
|
|
1431
1434
|
function renderGraphView() {
|
|
1432
1435
|
const area = $('content-area');
|
|
1433
1436
|
area.innerHTML = `
|
|
@@ -1436,24 +1439,19 @@ function renderGraphView() {
|
|
|
1436
1439
|
<div class="graph-header">
|
|
1437
1440
|
<div class="graph-header-left">
|
|
1438
1441
|
<span class="acc">▦</span>
|
|
1439
|
-
<
|
|
1442
|
+
<button class="tool-chip graph-mode active" data-mode="projects">projects</button>
|
|
1443
|
+
<button class="tool-chip graph-mode" data-mode="sessions">sessions</button>
|
|
1440
1444
|
<span class="dim tab-nums" id="graph-stats"></span>
|
|
1441
1445
|
</div>
|
|
1442
1446
|
<div class="graph-header-right">
|
|
1443
|
-
<button class="tool-chip active">force</button>
|
|
1444
|
-
<button class="tool-chip">radial</button>
|
|
1445
|
-
<button class="tool-chip">time</button>
|
|
1447
|
+
<button class="tool-chip graph-layout active" data-layout="force">force</button>
|
|
1448
|
+
<button class="tool-chip graph-layout" data-layout="radial">radial</button>
|
|
1449
|
+
<button class="tool-chip graph-layout" data-layout="time">time</button>
|
|
1446
1450
|
<button class="ghost-btn" id="graph-close">✕</button>
|
|
1447
1451
|
</div>
|
|
1448
1452
|
</div>
|
|
1449
1453
|
<div class="graph-svg-wrap" id="graph-svg-wrap"></div>
|
|
1450
|
-
<div class="graph-legend">
|
|
1451
|
-
<div class="graph-legend-title">legend</div>
|
|
1452
|
-
<div class="graph-legend-item"><i style="width:8px;height:8px;border-radius:999px;background:var(--accent)"></i>notes</div>
|
|
1453
|
-
<div class="graph-legend-item"><i style="width:8px;height:8px;border-radius:999px;background:var(--panel-2);border:1px solid var(--muted)"></i>sessions</div>
|
|
1454
|
-
<div class="graph-legend-item"><i style="width:12px;height:1px;background:var(--line-2)"></i>link</div>
|
|
1455
|
-
<div class="graph-legend-item"><i style="width:12px;height:0;border-top:1px dashed var(--line-2)"></i>session ref</div>
|
|
1456
|
-
</div>
|
|
1454
|
+
<div class="graph-legend" id="graph-legend"></div>
|
|
1457
1455
|
</section>
|
|
1458
1456
|
<aside class="graph-rail" id="graph-rail">
|
|
1459
1457
|
<div class="rail-card">
|
|
@@ -1470,36 +1468,88 @@ function renderGraphView() {
|
|
|
1470
1468
|
</div>
|
|
1471
1469
|
`;
|
|
1472
1470
|
|
|
1473
|
-
|
|
1471
|
+
updateGraphLegend();
|
|
1472
|
+
|
|
1473
|
+
const closeBtn = $('graph-close');
|
|
1474
|
+
if (closeBtn) {
|
|
1475
|
+
closeBtn.addEventListener('click', (e) => {
|
|
1476
|
+
e.stopPropagation();
|
|
1477
|
+
setView('note');
|
|
1478
|
+
if (currentPath) loadNote(currentPath);
|
|
1479
|
+
else $('content-area').innerHTML = '<div style="padding:40px;color:var(--muted)">Select a note from the sidebar</div>';
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1474
1482
|
|
|
1475
|
-
//
|
|
1476
|
-
|
|
1477
|
-
|
|
1483
|
+
// Mode toggle (projects / sessions)
|
|
1484
|
+
document.querySelectorAll('.graph-mode').forEach(btn => {
|
|
1485
|
+
btn.addEventListener('click', () => {
|
|
1486
|
+
document.querySelectorAll('.graph-mode').forEach(b => b.classList.remove('active'));
|
|
1487
|
+
btn.classList.add('active');
|
|
1488
|
+
_graphMode = btn.dataset.mode;
|
|
1489
|
+
_graphData = null;
|
|
1490
|
+
updateGraphLegend();
|
|
1491
|
+
const activeLayout = document.querySelector('.graph-layout.active');
|
|
1492
|
+
buildFullGraph(activeLayout ? activeLayout.dataset.layout : 'force');
|
|
1493
|
+
});
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
// Layout toggle (force / radial / time)
|
|
1497
|
+
document.querySelectorAll('.graph-layout').forEach(chip => {
|
|
1478
1498
|
chip.addEventListener('click', () => {
|
|
1479
|
-
|
|
1499
|
+
document.querySelectorAll('.graph-layout').forEach(c => c.classList.remove('active'));
|
|
1480
1500
|
chip.classList.add('active');
|
|
1481
|
-
buildFullGraph(chip.
|
|
1501
|
+
buildFullGraph(chip.dataset.layout);
|
|
1482
1502
|
});
|
|
1483
1503
|
});
|
|
1484
1504
|
|
|
1485
1505
|
buildFullGraph('force');
|
|
1486
1506
|
}
|
|
1487
1507
|
|
|
1508
|
+
function updateGraphLegend() {
|
|
1509
|
+
const legend = $('graph-legend');
|
|
1510
|
+
if (!legend) return;
|
|
1511
|
+
if (_graphMode === 'sessions') {
|
|
1512
|
+
legend.innerHTML = `
|
|
1513
|
+
<div class="graph-legend-title">sessions</div>
|
|
1514
|
+
<div class="graph-legend-item"><i style="width:8px;height:8px;border-radius:999px;background:var(--panel-2);border:1px solid var(--muted)"></i>session</div>
|
|
1515
|
+
<div class="graph-legend-item"><i style="width:12px;height:0;border-top:1px dashed var(--line-2)"></i>temporal link</div>
|
|
1516
|
+
`;
|
|
1517
|
+
} else {
|
|
1518
|
+
legend.innerHTML = `
|
|
1519
|
+
<div class="graph-legend-title">projects</div>
|
|
1520
|
+
<div class="graph-legend-item"><i style="width:8px;height:8px;border-radius:999px;background:var(--accent)"></i>notes</div>
|
|
1521
|
+
<div class="graph-legend-item"><i style="width:12px;height:1px;background:var(--line-2)"></i>wikilink</div>
|
|
1522
|
+
`;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1488
1526
|
async function buildFullGraph(layout) {
|
|
1489
1527
|
const wrap = $('graph-svg-wrap');
|
|
1490
1528
|
if (!wrap) return;
|
|
1491
1529
|
wrap.innerHTML = '';
|
|
1492
1530
|
|
|
1493
1531
|
if (!_graphData) {
|
|
1494
|
-
_graphData = await fetchJSON(
|
|
1532
|
+
_graphData = await fetchJSON(`/api/graph?kind=${_graphMode}`);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
let nodes = _graphData.nodes.map(n => ({ ...n }));
|
|
1536
|
+
let links = _graphData.edges.map(e => ({ ...e }));
|
|
1537
|
+
|
|
1538
|
+
// For sessions mode, create temporal links between consecutive sessions
|
|
1539
|
+
if (_graphMode === 'sessions' && links.length === 0 && nodes.length > 1) {
|
|
1540
|
+
const sorted = [...nodes].sort((a, b) => a.id.localeCompare(b.id));
|
|
1541
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
1542
|
+
links.push({ source: sorted[i - 1].id, target: sorted[i].id });
|
|
1543
|
+
}
|
|
1495
1544
|
}
|
|
1496
1545
|
|
|
1497
|
-
const nodes = _graphData.nodes.map(n => ({ ...n }));
|
|
1498
|
-
const links = _graphData.edges.map(e => ({ ...e }));
|
|
1499
1546
|
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
|
1500
1547
|
|
|
1501
1548
|
$('graph-stats').textContent = `${nodes.length}n · ${links.length}e`;
|
|
1502
|
-
if (nodes.length === 0)
|
|
1549
|
+
if (nodes.length === 0) {
|
|
1550
|
+
wrap.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--muted);font-size:var(--fz-sm)">No nodes to display</div>';
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1503
1553
|
|
|
1504
1554
|
const rect = wrap.getBoundingClientRect();
|
|
1505
1555
|
const W = rect.width || 800;
|
|
@@ -1568,36 +1618,44 @@ async function buildFullGraph(layout) {
|
|
|
1568
1618
|
const s = typeof l.source === 'object' ? l.source : nodeMap.get(l.source);
|
|
1569
1619
|
const t = typeof l.target === 'object' ? l.target : nodeMap.get(l.target);
|
|
1570
1620
|
if (!s || !t) return;
|
|
1571
|
-
const
|
|
1621
|
+
const isTemporal = _graphMode === 'sessions';
|
|
1572
1622
|
edgeGroup.append('line')
|
|
1573
1623
|
.attr('x1', s.x).attr('y1', s.y).attr('x2', t.x).attr('y2', t.y)
|
|
1574
|
-
.attr('stroke', 'var(--line-2)').attr('stroke-width', 1.2)
|
|
1575
|
-
.attr('stroke-opacity',
|
|
1576
|
-
.attr('stroke-dasharray',
|
|
1624
|
+
.attr('stroke', 'var(--line-2)').attr('stroke-width', isTemporal ? 1 : 1.2)
|
|
1625
|
+
.attr('stroke-opacity', isTemporal ? 0.3 : 0.6)
|
|
1626
|
+
.attr('stroke-dasharray', isTemporal ? '4 3' : '')
|
|
1577
1627
|
.attr('stroke-linecap', 'round');
|
|
1578
1628
|
});
|
|
1579
1629
|
|
|
1580
1630
|
// Draw nodes
|
|
1581
1631
|
const nodeGroup = g.append('g');
|
|
1582
1632
|
nodes.forEach(n => {
|
|
1583
|
-
const
|
|
1633
|
+
const isPrimary = _graphMode === 'sessions' ? n.kind === 'session' : n.kind === 'note';
|
|
1634
|
+
const r = isPrimary ? 10 : 6;
|
|
1584
1635
|
const ng = nodeGroup.append('g').style('cursor', 'pointer');
|
|
1585
1636
|
|
|
1586
|
-
if (
|
|
1587
|
-
ng.append('circle').attr('cx', n.x).attr('cy', n.y).attr('r', r)
|
|
1588
|
-
.attr('fill', 'var(--bg-2)').attr('stroke', 'var(--muted)').attr('stroke-width', 1.2);
|
|
1589
|
-
} else {
|
|
1637
|
+
if (isPrimary) {
|
|
1590
1638
|
ng.append('circle').attr('cx', n.x).attr('cy', n.y).attr('r', r + 4)
|
|
1591
1639
|
.attr('fill', 'var(--accent)').attr('fill-opacity', 0.12);
|
|
1592
1640
|
ng.append('circle').attr('cx', n.x).attr('cy', n.y).attr('r', r)
|
|
1593
1641
|
.attr('fill', 'var(--panel-2)').attr('stroke', 'var(--accent)').attr('stroke-width', 1.5);
|
|
1642
|
+
} else {
|
|
1643
|
+
ng.append('circle').attr('cx', n.x).attr('cy', n.y).attr('r', r)
|
|
1644
|
+
.attr('fill', 'var(--bg-2)').attr('stroke', 'var(--muted)').attr('stroke-width', 1.2);
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
let label = n.title;
|
|
1648
|
+
if (_graphMode === 'sessions' && n.kind === 'session') {
|
|
1649
|
+
const m = n.id.match(/(\d{4}-\d{2}-\d{2})_(\d{2})(\d{2})/);
|
|
1650
|
+
label = m ? `${m[1]} ${m[2]}:${m[3]}` : n.title;
|
|
1594
1651
|
}
|
|
1652
|
+
if (label.length > 22) label = label.substring(0, 20) + '…';
|
|
1595
1653
|
|
|
1596
1654
|
ng.append('text').attr('x', n.x).attr('y', n.y + r + 14)
|
|
1597
1655
|
.attr('text-anchor', 'middle').attr('font-size', 11)
|
|
1598
|
-
.attr('fill',
|
|
1656
|
+
.attr('fill', isPrimary ? 'var(--muted)' : 'var(--dim)').attr('opacity', 0.9)
|
|
1599
1657
|
.style('font-family', 'JetBrains Mono, monospace')
|
|
1600
|
-
.text(
|
|
1658
|
+
.text(label);
|
|
1601
1659
|
|
|
1602
1660
|
ng.on('click', () => {
|
|
1603
1661
|
updateGraphRail(n, links, nodes);
|
package/kyp_mem/ui.py
CHANGED
|
@@ -30,13 +30,17 @@ def create_app(vault_path: str = None) -> FastAPI:
|
|
|
30
30
|
return JSONResponse(vault.get_stats())
|
|
31
31
|
|
|
32
32
|
@app.get("/api/graph")
|
|
33
|
-
def graph():
|
|
33
|
+
def graph(kind: str = "all"):
|
|
34
34
|
nodes = []
|
|
35
35
|
edges = []
|
|
36
36
|
seen_edges = set()
|
|
37
37
|
for path, note in vault.index.notes.items():
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
node_kind = "session" if "/Sessions/" in path else "note"
|
|
39
|
+
if kind == "projects" and node_kind == "session":
|
|
40
|
+
continue
|
|
41
|
+
if kind == "sessions" and node_kind == "note":
|
|
42
|
+
continue
|
|
43
|
+
nodes.append({"id": path, "title": note.title, "kind": node_kind, "tags": note.tags})
|
|
40
44
|
for link in (note.links or []):
|
|
41
45
|
target = None
|
|
42
46
|
link_lower = link.lower()
|
|
@@ -46,6 +50,11 @@ def create_app(vault_path: str = None) -> FastAPI:
|
|
|
46
50
|
target = p
|
|
47
51
|
break
|
|
48
52
|
if target and target != path:
|
|
53
|
+
target_kind = "session" if "/Sessions/" in target else "note"
|
|
54
|
+
if kind == "projects" and target_kind == "session":
|
|
55
|
+
continue
|
|
56
|
+
if kind == "sessions" and target_kind == "note":
|
|
57
|
+
continue
|
|
49
58
|
key = tuple(sorted([path, target]))
|
|
50
59
|
if key not in seen_edges:
|
|
51
60
|
seen_edges.add(key)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kyp-mem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
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"
|