pi-para 0.5.2

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.
Files changed (198) hide show
  1. package/CHANGELOG.md +214 -0
  2. package/README.md +305 -0
  3. package/SETUP.md +171 -0
  4. package/dist/capture.d.ts +34 -0
  5. package/dist/capture.d.ts.map +1 -0
  6. package/dist/capture.js +461 -0
  7. package/dist/capture.js.map +1 -0
  8. package/dist/cli.d.ts +15 -0
  9. package/dist/cli.d.ts.map +1 -0
  10. package/dist/cli.js +405 -0
  11. package/dist/cli.js.map +1 -0
  12. package/dist/commands.d.ts +15 -0
  13. package/dist/commands.d.ts.map +1 -0
  14. package/dist/commands.js +786 -0
  15. package/dist/commands.js.map +1 -0
  16. package/dist/context.d.ts +61 -0
  17. package/dist/context.d.ts.map +1 -0
  18. package/dist/context.js +289 -0
  19. package/dist/context.js.map +1 -0
  20. package/dist/daemon.d.ts +49 -0
  21. package/dist/daemon.d.ts.map +1 -0
  22. package/dist/daemon.js +226 -0
  23. package/dist/daemon.js.map +1 -0
  24. package/dist/frontmatter.d.ts +59 -0
  25. package/dist/frontmatter.d.ts.map +1 -0
  26. package/dist/frontmatter.js +170 -0
  27. package/dist/frontmatter.js.map +1 -0
  28. package/dist/gepa/dataset.d.ts +36 -0
  29. package/dist/gepa/dataset.d.ts.map +1 -0
  30. package/dist/gepa/dataset.js +148 -0
  31. package/dist/gepa/dataset.js.map +1 -0
  32. package/dist/gepa/evaluator.d.ts +51 -0
  33. package/dist/gepa/evaluator.d.ts.map +1 -0
  34. package/dist/gepa/evaluator.js +317 -0
  35. package/dist/gepa/evaluator.js.map +1 -0
  36. package/dist/gepa/index.d.ts +66 -0
  37. package/dist/gepa/index.d.ts.map +1 -0
  38. package/dist/gepa/index.js +240 -0
  39. package/dist/gepa/index.js.map +1 -0
  40. package/dist/gepa/llm.d.ts +35 -0
  41. package/dist/gepa/llm.d.ts.map +1 -0
  42. package/dist/gepa/llm.js +268 -0
  43. package/dist/gepa/llm.js.map +1 -0
  44. package/dist/gepa/optimized/capture-prompt.txt +65 -0
  45. package/dist/gepa/optimizer.d.ts +29 -0
  46. package/dist/gepa/optimizer.d.ts.map +1 -0
  47. package/dist/gepa/optimizer.js +249 -0
  48. package/dist/gepa/optimizer.js.map +1 -0
  49. package/dist/gepa/targets.d.ts +39 -0
  50. package/dist/gepa/targets.d.ts.map +1 -0
  51. package/dist/gepa/targets.js +326 -0
  52. package/dist/gepa/targets.js.map +1 -0
  53. package/dist/gepa/types.d.ts +168 -0
  54. package/dist/gepa/types.d.ts.map +1 -0
  55. package/dist/gepa/types.js +124 -0
  56. package/dist/gepa/types.js.map +1 -0
  57. package/dist/index.d.ts +21 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +294 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/ingest.d.ts +88 -0
  62. package/dist/ingest.d.ts.map +1 -0
  63. package/dist/ingest.js +105 -0
  64. package/dist/ingest.js.map +1 -0
  65. package/dist/link-discovery.d.ts +40 -0
  66. package/dist/link-discovery.d.ts.map +1 -0
  67. package/dist/link-discovery.js +122 -0
  68. package/dist/link-discovery.js.map +1 -0
  69. package/dist/link-utils.d.ts +30 -0
  70. package/dist/link-utils.d.ts.map +1 -0
  71. package/dist/link-utils.js +145 -0
  72. package/dist/link-utils.js.map +1 -0
  73. package/dist/lint.d.ts +39 -0
  74. package/dist/lint.d.ts.map +1 -0
  75. package/dist/lint.js +827 -0
  76. package/dist/lint.js.map +1 -0
  77. package/dist/maintainer.d.ts +24 -0
  78. package/dist/maintainer.d.ts.map +1 -0
  79. package/dist/maintainer.js +358 -0
  80. package/dist/maintainer.js.map +1 -0
  81. package/dist/processor.d.ts +14 -0
  82. package/dist/processor.d.ts.map +1 -0
  83. package/dist/processor.js +361 -0
  84. package/dist/processor.js.map +1 -0
  85. package/dist/query.d.ts +69 -0
  86. package/dist/query.d.ts.map +1 -0
  87. package/dist/query.js +107 -0
  88. package/dist/query.js.map +1 -0
  89. package/dist/raw.d.ts +57 -0
  90. package/dist/raw.d.ts.map +1 -0
  91. package/dist/raw.js +219 -0
  92. package/dist/raw.js.map +1 -0
  93. package/dist/redact.d.ts +21 -0
  94. package/dist/redact.d.ts.map +1 -0
  95. package/dist/redact.js +143 -0
  96. package/dist/redact.js.map +1 -0
  97. package/dist/scope.d.ts +54 -0
  98. package/dist/scope.d.ts.map +1 -0
  99. package/dist/scope.js +161 -0
  100. package/dist/scope.js.map +1 -0
  101. package/dist/scripts/bulk-link-fix.d.ts +9 -0
  102. package/dist/scripts/bulk-link-fix.d.ts.map +1 -0
  103. package/dist/scripts/bulk-link-fix.js +106 -0
  104. package/dist/scripts/bulk-link-fix.js.map +1 -0
  105. package/dist/session-tools.d.ts +33 -0
  106. package/dist/session-tools.d.ts.map +1 -0
  107. package/dist/session-tools.js +211 -0
  108. package/dist/session-tools.js.map +1 -0
  109. package/dist/state.d.ts +54 -0
  110. package/dist/state.d.ts.map +1 -0
  111. package/dist/state.js +176 -0
  112. package/dist/state.js.map +1 -0
  113. package/dist/store.d.ts +56 -0
  114. package/dist/store.d.ts.map +1 -0
  115. package/dist/store.js +227 -0
  116. package/dist/store.js.map +1 -0
  117. package/dist/summarize.d.ts +56 -0
  118. package/dist/summarize.d.ts.map +1 -0
  119. package/dist/summarize.js +231 -0
  120. package/dist/summarize.js.map +1 -0
  121. package/dist/tag-registry.d.ts +56 -0
  122. package/dist/tag-registry.d.ts.map +1 -0
  123. package/dist/tag-registry.js +146 -0
  124. package/dist/tag-registry.js.map +1 -0
  125. package/dist/templates/prompts.d.ts +63 -0
  126. package/dist/templates/prompts.d.ts.map +1 -0
  127. package/dist/templates/prompts.js +260 -0
  128. package/dist/templates/prompts.js.map +1 -0
  129. package/dist/tools.d.ts +26 -0
  130. package/dist/tools.d.ts.map +1 -0
  131. package/dist/tools.js +1033 -0
  132. package/dist/tools.js.map +1 -0
  133. package/dist/watcher.d.ts +25 -0
  134. package/dist/watcher.d.ts.map +1 -0
  135. package/dist/watcher.js +84 -0
  136. package/dist/watcher.js.map +1 -0
  137. package/dist/webui/client/dist/index.html +1885 -0
  138. package/dist/webui/server.d.ts +31 -0
  139. package/dist/webui/server.d.ts.map +1 -0
  140. package/dist/webui/server.js +566 -0
  141. package/dist/webui/server.js.map +1 -0
  142. package/dist/wiki.d.ts +103 -0
  143. package/dist/wiki.d.ts.map +1 -0
  144. package/dist/wiki.js +428 -0
  145. package/dist/wiki.js.map +1 -0
  146. package/package.json +142 -0
  147. package/scripts/gepa/__pycache__/dataset.cpython-313.pyc +0 -0
  148. package/scripts/gepa/__pycache__/lm_providers.cpython-313.pyc +0 -0
  149. package/scripts/gepa/__pycache__/metric.cpython-313.pyc +0 -0
  150. package/scripts/gepa/__pycache__/program.cpython-313.pyc +0 -0
  151. package/scripts/gepa/__pycache__/wiki_reader.cpython-313.pyc +0 -0
  152. package/scripts/gepa/dataset.py +192 -0
  153. package/scripts/gepa/lm_providers.py +472 -0
  154. package/scripts/gepa/metric.py +279 -0
  155. package/scripts/gepa/optimize.py +367 -0
  156. package/scripts/gepa/program.py +99 -0
  157. package/scripts/gepa/pyproject.toml +19 -0
  158. package/scripts/gepa/run-all.sh +53 -0
  159. package/scripts/gepa/uv.lock +2241 -0
  160. package/scripts/gepa/wiki_reader.py +113 -0
  161. package/skills/para/SKILL.md +77 -0
  162. package/skills/setup/SKILL.md +20 -0
  163. package/src/capture.ts +563 -0
  164. package/src/cli.ts +416 -0
  165. package/src/commands.ts +878 -0
  166. package/src/context.ts +390 -0
  167. package/src/daemon.ts +288 -0
  168. package/src/frontmatter.ts +219 -0
  169. package/src/gepa/index.ts +287 -0
  170. package/src/gepa/optimized/capture-prompt.txt +65 -0
  171. package/src/index.ts +384 -0
  172. package/src/ingest.ts +177 -0
  173. package/src/link-discovery.ts +170 -0
  174. package/src/link-utils.ts +169 -0
  175. package/src/lint.ts +1018 -0
  176. package/src/maintainer.ts +404 -0
  177. package/src/processor.ts +415 -0
  178. package/src/python/__pycache__/minimax_lm.cpython-312.pyc +0 -0
  179. package/src/python/minimax_lm.py +198 -0
  180. package/src/python/rlm_capture.py +215 -0
  181. package/src/query.ts +148 -0
  182. package/src/raw.ts +276 -0
  183. package/src/redact.ts +159 -0
  184. package/src/scope.ts +214 -0
  185. package/src/scripts/bulk-link-fix.ts +126 -0
  186. package/src/session-tools.ts +260 -0
  187. package/src/state.ts +271 -0
  188. package/src/store.ts +288 -0
  189. package/src/summarize.ts +273 -0
  190. package/src/tag-registry.ts +157 -0
  191. package/src/templates/index.md +17 -0
  192. package/src/templates/prompts.ts +274 -0
  193. package/src/templates/schema.md +116 -0
  194. package/src/tools.ts +1293 -0
  195. package/src/watcher.ts +101 -0
  196. package/src/webui/client/dist/index.html +1885 -0
  197. package/src/webui/server.ts +765 -0
  198. package/src/wiki.ts +550 -0
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ RLM-based session knowledge capture for pi-para.
4
+
5
+ Uses dspy.RLM with MiniMaxLM to extract knowledge from arbitrarily
6
+ large pi sessions. RLM handles chunking itself via sandboxed Python REPL.
7
+
8
+ Usage:
9
+ python3 rlm_capture.py <session_jsonl> <wiki_dir> <scope_name> [--already-captured slug1,slug2]
10
+ """
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ import dspy
19
+ import yaml
20
+
21
+ sys.path.insert(0, str(Path(__file__).parent))
22
+ from minimax_lm import MiniMaxLM
23
+
24
+
25
+ def load_session(session_path: str) -> str:
26
+ """Load and serialize a session JSONL file."""
27
+ entries = []
28
+ with open(session_path) as f:
29
+ for line in f:
30
+ line = line.strip()
31
+ if not line:
32
+ continue
33
+ try:
34
+ entries.append(json.loads(line))
35
+ except json.JSONDecodeError:
36
+ continue
37
+
38
+ parts = []
39
+ for entry in entries:
40
+ if entry.get("type") != "message":
41
+ if entry.get("type") == "compaction" and entry.get("summary"):
42
+ parts.append(f"[Compaction Summary]:\n{entry['summary']}")
43
+ continue
44
+
45
+ msg = entry.get("message", {})
46
+ role = msg.get("role", "")
47
+ content = msg.get("content", "")
48
+ if role not in ("user", "assistant", "toolResult"):
49
+ continue
50
+
51
+ if isinstance(content, str):
52
+ text = content
53
+ elif isinstance(content, list):
54
+ text_parts = []
55
+ tool_calls = []
56
+ for block in content:
57
+ if not isinstance(block, dict):
58
+ continue
59
+ if block.get("type") == "text" and block.get("text"):
60
+ text_parts.append(block["text"])
61
+ elif block.get("type") == "toolCall":
62
+ name = block.get("name", "?")
63
+ args_str = json.dumps(block.get("arguments", {}))
64
+ if len(args_str) > 300:
65
+ args_str = args_str[:300] + "..."
66
+ tool_calls.append(f"{name}({args_str})")
67
+ text = "\n".join(text_parts)
68
+ if tool_calls:
69
+ text += f"\n[Tools]: {'; '.join(tool_calls)}"
70
+ else:
71
+ continue
72
+
73
+ if not text.strip():
74
+ continue
75
+ if role == "toolResult" and len(text) > 1000:
76
+ text = text[:1000] + "\n[truncated]"
77
+
78
+ label = {"user": "User", "assistant": "Assistant", "toolResult": "Tool"}.get(role, role)
79
+ parts.append(f"[{label}]: {text}")
80
+
81
+ return "\n\n".join(parts)
82
+
83
+
84
+ def load_wiki_pages(wiki_dir: str, slugs: list[str]) -> str:
85
+ if not slugs:
86
+ return ""
87
+ pages = []
88
+ for slug in slugs:
89
+ for cat in ["projects", "areas", "resources", "archives"]:
90
+ path = Path(wiki_dir) / cat / f"{slug}.md"
91
+ if path.exists():
92
+ content = path.read_text()
93
+ if content.startswith("---"):
94
+ end = content.find("---", 3)
95
+ if end > 0:
96
+ content = content[end + 3:].strip()
97
+ pages.append(f"[[{slug}]]: {content[:2000]}")
98
+ break
99
+ return "\n\n".join(pages) if pages else ""
100
+
101
+
102
+ def setup_lm() -> MiniMaxLM:
103
+ api_key = os.environ.get("MINIMAX_CN_API_KEY")
104
+ if not api_key:
105
+ config_path = Path.home() / ".config" / "qmd" / "index.yml"
106
+ if config_path.exists():
107
+ with open(config_path) as f:
108
+ cfg = yaml.safe_load(f) or {}
109
+ chat = cfg.get("providers", {}).get("chat", {})
110
+ if chat.get("key") and "minimaxi.com" in chat.get("url", ""):
111
+ api_key = chat["key"]
112
+ if not api_key:
113
+ print("Error: No MiniMax API key.", file=sys.stderr)
114
+ sys.exit(1)
115
+
116
+ os.environ["MINIMAX_CN_API_KEY"] = api_key
117
+ return MiniMaxLM(model="MiniMax-M2.7-highspeed", china=True, max_tokens=8192)
118
+
119
+
120
+ def run_capture(session_text: str, wiki_context: str, scope: str, lm: MiniMaxLM) -> dict:
121
+ dspy.configure(lm=lm)
122
+
123
+ rlm = dspy.RLM(
124
+ "context, query -> answer",
125
+ max_iterations=50,
126
+ max_llm_calls=100,
127
+ max_output_chars=30_000,
128
+ sub_lm=lm,
129
+ verbose=True,
130
+ )
131
+
132
+ query = f"""You have a large coding session transcript in the `context` variable ({len(session_text)} chars).
133
+ It's too large to process in one llm_query() call. Use Python to work with it efficiently:
134
+
135
+ 1. Use Python string slicing to examine sections: context[:5000], context[5000:10000], etc.
136
+ 2. Use Python's `in` operator or string methods to search: "keyword" in context, context.find("...")
137
+ 3. For each interesting section found, use llm_query() on SMALL excerpts (under 20000 chars)
138
+ 4. Accumulate findings in a Python list
139
+
140
+ {f"Already captured (skip these topics): {wiki_context}" if wiki_context else ""}
141
+
142
+ Extract ALL valuable knowledge:
143
+ - Architecture decisions, debugging solutions, server details
144
+ - Build/deploy procedures, tool configs, package names+versions
145
+ - File paths, conventions, operational knowledge
146
+
147
+ When done, SUBMIT a JSON array:
148
+ [{{"category":"projects|areas|resources|archives","slug":"name","title":"Title","scope":["{scope}"],"tags":["t1"],"body":"## Topic\\n...\\n## Key Facts\\n- ..."}}]
149
+
150
+ If nothing valuable found, SUBMIT: []"""
151
+
152
+ try:
153
+ result = rlm(context=session_text, query=query)
154
+ answer = result.answer.strip()
155
+ except Exception as e:
156
+ return {"pages": [], "error": str(e)}
157
+
158
+ # Extract JSON
159
+ if "```" in answer:
160
+ lines = answer.split("\n")
161
+ json_lines = []
162
+ in_block = False
163
+ for line in lines:
164
+ if line.strip().startswith("```"):
165
+ in_block = not in_block
166
+ continue
167
+ if in_block:
168
+ json_lines.append(line)
169
+ if json_lines:
170
+ answer = "\n".join(json_lines)
171
+
172
+ start = answer.find("[")
173
+ end = answer.rfind("]")
174
+ if start >= 0 and end > start:
175
+ answer = answer[start:end + 1]
176
+
177
+ try:
178
+ pages = json.loads(answer)
179
+ return {"pages": pages, "error": None} if isinstance(pages, list) else {"pages": [], "error": "not array"}
180
+ except json.JSONDecodeError as e:
181
+ return {"pages": [], "error": f"JSON: {e}\nRaw: {answer[:500]}"}
182
+
183
+
184
+ def main():
185
+ parser = argparse.ArgumentParser()
186
+ parser.add_argument("session_jsonl")
187
+ parser.add_argument("wiki_dir")
188
+ parser.add_argument("scope_name")
189
+ parser.add_argument("--already-captured", default="")
190
+ args = parser.parse_args()
191
+
192
+ lm = setup_lm()
193
+
194
+ print(f"Loading: {args.session_jsonl}", file=sys.stderr)
195
+ session_text = load_session(args.session_jsonl)
196
+ print(f"Serialized: {len(session_text)} chars", file=sys.stderr)
197
+
198
+ if not session_text.strip():
199
+ json.dump({"pages": [], "error": "empty"}, sys.stdout)
200
+ return
201
+
202
+ already = [s.strip() for s in args.already_captured.split(",") if s.strip()]
203
+ wiki_context = load_wiki_pages(args.wiki_dir, already)
204
+
205
+ print("Running RLM capture...", file=sys.stderr)
206
+ result = run_capture(session_text, wiki_context, args.scope_name, lm)
207
+
208
+ if result.get("error"):
209
+ print(f"Error: {result['error']}", file=sys.stderr)
210
+ print(f"Result: {len(result.get('pages', []))} pages", file=sys.stderr)
211
+ json.dump(result, sys.stdout, indent=2)
212
+
213
+
214
+ if __name__ == "__main__":
215
+ main()
package/src/query.ts ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Query pipeline — searches the wiki and returns relevant results.
3
+ *
4
+ * Performs scoped search via qmd and returns results for the LLM
5
+ * to synthesize answers from. This is the reusable library function;
6
+ * the wiki_query tool wraps it with LLM prompt formatting.
7
+ */
8
+
9
+ import type { QMDStore } from "qmd-engine";
10
+ import type { ParaCategory } from "./wiki.js";
11
+ import { searchWiki } from "./store.js";
12
+ import type { WikiSearchResult } from "./store.js";
13
+ import type { ProjectScope } from "./scope.js";
14
+
15
+ // -- Types ------------------------------------------------------------------
16
+
17
+ export interface QueryOptions {
18
+ /** Natural language search query */
19
+ query: string;
20
+ /** Override auto-detected scope tags */
21
+ scope?: string[];
22
+ /** Search all scopes (ignore project filter) */
23
+ global?: boolean;
24
+ /** Restrict to a PARA category */
25
+ category?: ParaCategory;
26
+ /** Maximum results (default 10) */
27
+ limit?: number;
28
+ /** Include archives in results (default false) */
29
+ includeArchives?: boolean;
30
+ /** Also search the raw/ collection (default false) */
31
+ includeRaw?: boolean;
32
+ /** Enable graph-boosted search via 1-hop wikilink expansion (default true) */
33
+ graphBoost?: boolean;
34
+ }
35
+
36
+ export interface QueryResult {
37
+ /** Search results with page metadata and snippets */
38
+ results: WikiSearchResult[];
39
+ /** The scope that was used for filtering */
40
+ scopeUsed: ProjectScope;
41
+ /** Whether the search was global (no scope filtering) */
42
+ wasGlobal: boolean;
43
+ }
44
+
45
+ // -- Main pipeline -----------------------------------------------------------
46
+
47
+ /**
48
+ * Search the wiki with scope and category filtering.
49
+ *
50
+ * This is the reusable library function. It:
51
+ * 1. Applies scope filtering (unless global flag is set)
52
+ * 2. Filters by PARA category if specified
53
+ * 3. Excludes archives unless explicitly requested
54
+ * 4. Returns results sorted by relevance score
55
+ *
56
+ * The caller (tool or standalone agent) can format the results
57
+ * into an LLM prompt or use them directly.
58
+ */
59
+ export async function queryWiki(
60
+ store: QMDStore,
61
+ options: QueryOptions,
62
+ scope: ProjectScope,
63
+ ): Promise<QueryResult> {
64
+ const limit = options.limit ?? 10;
65
+ const isGlobal = options.global ?? false;
66
+ const includeArchives = options.includeArchives ?? (options.category === "archives");
67
+
68
+ // Build scope override if explicit scope tags provided
69
+ let effectiveScope: ProjectScope | undefined;
70
+ if (isGlobal) {
71
+ effectiveScope = undefined; // no scope filtering
72
+ } else if (options.scope && options.scope.length > 0) {
73
+ effectiveScope = {
74
+ ...scope,
75
+ name: scope.name,
76
+ include: options.scope,
77
+ };
78
+ } else {
79
+ effectiveScope = scope;
80
+ }
81
+
82
+ const results = await searchWiki(store, options.query, {
83
+ scope: effectiveScope,
84
+ category: options.category,
85
+ limit,
86
+ includeArchives,
87
+ graphBoost: options.graphBoost,
88
+ });
89
+
90
+ return {
91
+ results,
92
+ scopeUsed: effectiveScope ?? scope,
93
+ wasGlobal: isGlobal,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Format query results as a readable text block.
99
+ * Useful for building LLM prompts or display.
100
+ *
101
+ * Includes freshness indicators so the LLM knows which pages to trust
102
+ * vs. which need verification against current code/state.
103
+ */
104
+ export function formatQueryResults(results: WikiSearchResult[]): string {
105
+ if (results.length === 0) return "No results found.";
106
+
107
+ const now = Date.now();
108
+
109
+ return results
110
+ .map((r, i) => {
111
+ const fm = r.frontmatter;
112
+ const freshness = formatFreshness(fm.updated, now);
113
+ return [
114
+ `### Result ${i + 1}: ${fm.title} (${r.page.path})`,
115
+ `PARA: ${fm.para} | Scope: ${fm.scope.join(", ")} | Tags: ${fm.tags.join(", ")}`,
116
+ `Updated: ${fm.updated} | ${freshness}`,
117
+ `Score: ${r.score.toFixed(3)}`,
118
+ "",
119
+ r.snippet,
120
+ ].join("\n");
121
+ })
122
+ .join("\n\n");
123
+ }
124
+
125
+ // -- Freshness helpers -------------------------------------------------------
126
+
127
+ /**
128
+ * Compute a human-readable freshness indicator from the page's `updated` date.
129
+ *
130
+ * - < 7 days → "✅ FRESH"
131
+ * - 7-14 days → "✅ Recent — N days old"
132
+ * - 14-30 days → "⚠️ AGING — N days old — verify claims about code/configs"
133
+ * - 30-90 days → "⚠️ STALE — N days old — verify before trusting"
134
+ * - > 90 days → "🚨 VERY STALE — N days old — likely outdated, verify everything"
135
+ */
136
+ export function formatFreshness(updatedISO: string, nowMs: number): string {
137
+ const updatedMs = Date.parse(updatedISO);
138
+ if (isNaN(updatedMs)) return "❓ Unknown freshness (no valid date)";
139
+
140
+ const ageDays = Math.floor((nowMs - updatedMs) / (1000 * 60 * 60 * 24));
141
+
142
+ if (ageDays < 0) return "✅ FRESH";
143
+ if (ageDays < 7) return "✅ FRESH";
144
+ if (ageDays < 14) return `✅ Recent — ${ageDays} days old`;
145
+ if (ageDays < 30) return `⚠️ AGING — ${ageDays} days old — verify claims about code/configs`;
146
+ if (ageDays < 90) return `⚠️ STALE — ${ageDays} days old — verify before trusting`;
147
+ return `🚨 VERY STALE — ${ageDays} days old — likely outdated, verify everything`;
148
+ }
package/src/raw.ts ADDED
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Raw source vault and session digests.
3
+ *
4
+ * Manages immutable source material in ~/.pi/wiki/raw/ and
5
+ * session digest entries in sessions.md.
6
+ */
7
+
8
+ import { readFile, writeFile, mkdir, access } from "node:fs/promises";
9
+ import { join, basename, extname } from "node:path";
10
+
11
+ // -- Types ------------------------------------------------------------------
12
+
13
+ export interface RawSource {
14
+ type: "url" | "file" | "text";
15
+ originalPath: string;
16
+ savedPath: string; // path within raw/ directory
17
+ ingestedAt: string; // ISO date
18
+ wikiPages: string[]; // wiki pages derived from this source
19
+ }
20
+
21
+ export interface SessionDigest {
22
+ date: string;
23
+ project: string;
24
+ sessionFile: string;
25
+ scope: string;
26
+ capturedPages: string[];
27
+ summary: string;
28
+ }
29
+
30
+ // -- Helpers -----------------------------------------------------------------
31
+
32
+ const RAW_SUBDIRS: Record<"url" | "file" | "text", string> = {
33
+ url: "articles",
34
+ file: "docs",
35
+ text: "notes",
36
+ };
37
+
38
+ /**
39
+ * Slugify a string for use as a filename. Lowercase, hyphens, no special
40
+ * characters. Collapses multiple hyphens. Trims leading/trailing hyphens.
41
+ */
42
+ function slugify(input: string): string {
43
+ return input
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9]+/g, "-")
46
+ .replace(/-+/g, "-")
47
+ .replace(/^-|-$/g, "");
48
+ }
49
+
50
+ /**
51
+ * Derive a filename slug from the original path based on source type.
52
+ *
53
+ * - URL: extract domain + path components
54
+ * - file: use the filename without extension
55
+ * - text: use a timestamp-based name
56
+ */
57
+ function deriveSlug(type: "url" | "file" | "text", originalPath: string): string {
58
+ if (type === "url") {
59
+ try {
60
+ const url = new URL(originalPath);
61
+ const host = url.hostname.replace(/^www\./, "");
62
+ const pathParts = url.pathname
63
+ .split("/")
64
+ .filter((p) => p.length > 0)
65
+ .slice(0, 3);
66
+ const raw = [host, ...pathParts].join("-");
67
+ return slugify(raw) || "untitled";
68
+ } catch {
69
+ return slugify(originalPath) || "untitled";
70
+ }
71
+ }
72
+
73
+ if (type === "file") {
74
+ const name = basename(originalPath, extname(originalPath));
75
+ return slugify(name) || "untitled";
76
+ }
77
+
78
+ // text: timestamp-based
79
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
80
+ return `note-${ts}`;
81
+ }
82
+
83
+ async function fileExists(path: string): Promise<boolean> {
84
+ try {
85
+ await access(path);
86
+ return true;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Ensure a unique filename by appending -2, -3, etc. if the slug already exists.
94
+ */
95
+ async function uniquePath(dir: string, slug: string, ext: string): Promise<string> {
96
+ let candidate = join(dir, `${slug}${ext}`);
97
+ if (!(await fileExists(candidate))) return candidate;
98
+
99
+ let i = 2;
100
+ while (await fileExists(join(dir, `${slug}-${i}${ext}`))) {
101
+ i++;
102
+ }
103
+ return join(dir, `${slug}-${i}${ext}`);
104
+ }
105
+
106
+ // -- Functions ---------------------------------------------------------------
107
+
108
+ /**
109
+ * Save a raw source to the vault. URL sources go to raw/articles/,
110
+ * file sources to raw/docs/, text sources to raw/notes/.
111
+ *
112
+ * The file is saved as markdown with a metadata header.
113
+ * Returns the path relative to wikiDir (e.g. "raw/articles/example-com-page.md").
114
+ */
115
+ export async function saveRawSource(
116
+ wikiDir: string,
117
+ source: {
118
+ type: "url" | "file" | "text";
119
+ content: string;
120
+ originalPath: string;
121
+ },
122
+ ): Promise<string> {
123
+ const subdir = RAW_SUBDIRS[source.type];
124
+ const dir = join(wikiDir, "raw", subdir);
125
+ await mkdir(dir, { recursive: true });
126
+
127
+ const slug = deriveSlug(source.type, source.originalPath);
128
+ const filePath = await uniquePath(dir, slug, ".md");
129
+ const fileName = basename(filePath);
130
+
131
+ const header = [
132
+ "---",
133
+ `source_type: ${source.type}`,
134
+ `original_path: "${source.originalPath}"`,
135
+ `ingested_at: "${new Date().toISOString()}"`,
136
+ "---",
137
+ "",
138
+ ].join("\n");
139
+
140
+ await writeFile(filePath, header + source.content, "utf-8");
141
+
142
+ return `raw/${subdir}/${fileName}`;
143
+ }
144
+
145
+ /**
146
+ * Append a session digest entry to sessions.md in the documented format:
147
+ *
148
+ * ```
149
+ * ## [2026-04-27] pi-mono | SSL certificate debugging
150
+ * - **Session**: `~/.pi/agent/sessions/.../file.jsonl`
151
+ * - **Scope**: pi-mono
152
+ * - **Captured**: [[ssl-cert-gotchas]]
153
+ * - **Summary**: One-paragraph summary...
154
+ * ```
155
+ *
156
+ * Creates sessions.md if it does not exist.
157
+ */
158
+ export async function appendSessionDigest(
159
+ wikiDir: string,
160
+ digest: SessionDigest,
161
+ ): Promise<void> {
162
+ const sessionsPath = join(wikiDir, "sessions.md");
163
+
164
+ let existing: string;
165
+ try {
166
+ existing = await readFile(sessionsPath, "utf-8");
167
+ } catch (err: unknown) {
168
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
169
+ existing = "# Session Digests\n";
170
+ } else {
171
+ throw err;
172
+ }
173
+ }
174
+
175
+ const captured = digest.capturedPages.map((p) => `[[${p}]]`).join(", ");
176
+ const heading = digest.summary.split(/[.!?\n]/)[0].trim() || "Session capture";
177
+
178
+ const entry = [
179
+ "",
180
+ `## [${digest.date}] ${digest.project} | ${heading}`,
181
+ `- **Session**: \`${digest.sessionFile}\``,
182
+ `- **Scope**: ${digest.scope}`,
183
+ `- **Captured**: ${captured || "none"}`,
184
+ `- **Summary**: ${digest.summary}`,
185
+ "",
186
+ ].join("\n");
187
+
188
+ await writeFile(sessionsPath, existing + entry, "utf-8");
189
+ }
190
+
191
+ /**
192
+ * Parse sessions.md and return session digest entries.
193
+ * Optionally filter by scope and/or limit the number of results.
194
+ * Results are returned in file order (oldest first).
195
+ */
196
+ export async function readSessionDigests(
197
+ wikiDir: string,
198
+ options?: { limit?: number; scope?: string },
199
+ ): Promise<SessionDigest[]> {
200
+ const sessionsPath = join(wikiDir, "sessions.md");
201
+
202
+ let content: string;
203
+ try {
204
+ content = await readFile(sessionsPath, "utf-8");
205
+ } catch (err: unknown) {
206
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
207
+ return [];
208
+ }
209
+ throw err;
210
+ }
211
+
212
+ const digests: SessionDigest[] = [];
213
+
214
+ // Split on ## headings (session entries)
215
+ const entryPattern = /^## \[([^\]]+)\] ([^|]+)\| (.+)$/gm;
216
+ let match: RegExpExecArray | null;
217
+
218
+ while ((match = entryPattern.exec(content)) !== null) {
219
+ const date = match[1].trim();
220
+ const project = match[2].trim();
221
+ // Extract the block after this heading until the next heading or end
222
+ const startIdx = match.index + match[0].length;
223
+ const nextHeading = content.indexOf("\n## ", startIdx);
224
+ const block = nextHeading === -1
225
+ ? content.slice(startIdx)
226
+ : content.slice(startIdx, nextHeading);
227
+
228
+ const sessionFile = extractField(block, "Session");
229
+ const scope = extractField(block, "Scope");
230
+ const capturedRaw = extractField(block, "Captured");
231
+ const summary = extractField(block, "Summary");
232
+
233
+ // Parse captured wikilinks: [[slug1]], [[slug2]]
234
+ const capturedPages: string[] = [];
235
+ const linkPattern = /\[\[([^\]]+)\]\]/g;
236
+ let linkMatch: RegExpExecArray | null;
237
+ while ((linkMatch = linkPattern.exec(capturedRaw)) !== null) {
238
+ capturedPages.push(linkMatch[1]);
239
+ }
240
+
241
+ digests.push({
242
+ date,
243
+ project,
244
+ sessionFile,
245
+ scope,
246
+ capturedPages,
247
+ summary,
248
+ });
249
+ }
250
+
251
+ // Apply scope filter
252
+ let filtered = digests;
253
+ if (options?.scope) {
254
+ const scopeFilter = options.scope;
255
+ filtered = filtered.filter((d) => d.scope === scopeFilter);
256
+ }
257
+
258
+ // Apply limit (return the last N entries — most recent)
259
+ if (options?.limit !== undefined && options.limit < filtered.length) {
260
+ filtered = filtered.slice(filtered.length - options.limit);
261
+ }
262
+
263
+ return filtered;
264
+ }
265
+
266
+ /**
267
+ * Extract a field value from a session digest block.
268
+ * Matches lines like: `- **Field**: value` or `- **Field**: \`value\``
269
+ */
270
+ function extractField(block: string, field: string): string {
271
+ const pattern = new RegExp(`^- \\*\\*${field}\\*\\*:\\s*(.+)$`, "m");
272
+ const match = pattern.exec(block);
273
+ if (!match) return "";
274
+ // Strip backticks wrapping the value
275
+ return match[1].trim().replace(/^`|`$/g, "");
276
+ }