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.
- package/CHANGELOG.md +214 -0
- package/README.md +305 -0
- package/SETUP.md +171 -0
- package/dist/capture.d.ts +34 -0
- package/dist/capture.d.ts.map +1 -0
- package/dist/capture.js +461 -0
- package/dist/capture.js.map +1 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +405 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands.d.ts +15 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +786 -0
- package/dist/commands.js.map +1 -0
- package/dist/context.d.ts +61 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +289 -0
- package/dist/context.js.map +1 -0
- package/dist/daemon.d.ts +49 -0
- package/dist/daemon.d.ts.map +1 -0
- package/dist/daemon.js +226 -0
- package/dist/daemon.js.map +1 -0
- package/dist/frontmatter.d.ts +59 -0
- package/dist/frontmatter.d.ts.map +1 -0
- package/dist/frontmatter.js +170 -0
- package/dist/frontmatter.js.map +1 -0
- package/dist/gepa/dataset.d.ts +36 -0
- package/dist/gepa/dataset.d.ts.map +1 -0
- package/dist/gepa/dataset.js +148 -0
- package/dist/gepa/dataset.js.map +1 -0
- package/dist/gepa/evaluator.d.ts +51 -0
- package/dist/gepa/evaluator.d.ts.map +1 -0
- package/dist/gepa/evaluator.js +317 -0
- package/dist/gepa/evaluator.js.map +1 -0
- package/dist/gepa/index.d.ts +66 -0
- package/dist/gepa/index.d.ts.map +1 -0
- package/dist/gepa/index.js +240 -0
- package/dist/gepa/index.js.map +1 -0
- package/dist/gepa/llm.d.ts +35 -0
- package/dist/gepa/llm.d.ts.map +1 -0
- package/dist/gepa/llm.js +268 -0
- package/dist/gepa/llm.js.map +1 -0
- package/dist/gepa/optimized/capture-prompt.txt +65 -0
- package/dist/gepa/optimizer.d.ts +29 -0
- package/dist/gepa/optimizer.d.ts.map +1 -0
- package/dist/gepa/optimizer.js +249 -0
- package/dist/gepa/optimizer.js.map +1 -0
- package/dist/gepa/targets.d.ts +39 -0
- package/dist/gepa/targets.d.ts.map +1 -0
- package/dist/gepa/targets.js +326 -0
- package/dist/gepa/targets.js.map +1 -0
- package/dist/gepa/types.d.ts +168 -0
- package/dist/gepa/types.d.ts.map +1 -0
- package/dist/gepa/types.js +124 -0
- package/dist/gepa/types.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +294 -0
- package/dist/index.js.map +1 -0
- package/dist/ingest.d.ts +88 -0
- package/dist/ingest.d.ts.map +1 -0
- package/dist/ingest.js +105 -0
- package/dist/ingest.js.map +1 -0
- package/dist/link-discovery.d.ts +40 -0
- package/dist/link-discovery.d.ts.map +1 -0
- package/dist/link-discovery.js +122 -0
- package/dist/link-discovery.js.map +1 -0
- package/dist/link-utils.d.ts +30 -0
- package/dist/link-utils.d.ts.map +1 -0
- package/dist/link-utils.js +145 -0
- package/dist/link-utils.js.map +1 -0
- package/dist/lint.d.ts +39 -0
- package/dist/lint.d.ts.map +1 -0
- package/dist/lint.js +827 -0
- package/dist/lint.js.map +1 -0
- package/dist/maintainer.d.ts +24 -0
- package/dist/maintainer.d.ts.map +1 -0
- package/dist/maintainer.js +358 -0
- package/dist/maintainer.js.map +1 -0
- package/dist/processor.d.ts +14 -0
- package/dist/processor.d.ts.map +1 -0
- package/dist/processor.js +361 -0
- package/dist/processor.js.map +1 -0
- package/dist/query.d.ts +69 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +107 -0
- package/dist/query.js.map +1 -0
- package/dist/raw.d.ts +57 -0
- package/dist/raw.d.ts.map +1 -0
- package/dist/raw.js +219 -0
- package/dist/raw.js.map +1 -0
- package/dist/redact.d.ts +21 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +143 -0
- package/dist/redact.js.map +1 -0
- package/dist/scope.d.ts +54 -0
- package/dist/scope.d.ts.map +1 -0
- package/dist/scope.js +161 -0
- package/dist/scope.js.map +1 -0
- package/dist/scripts/bulk-link-fix.d.ts +9 -0
- package/dist/scripts/bulk-link-fix.d.ts.map +1 -0
- package/dist/scripts/bulk-link-fix.js +106 -0
- package/dist/scripts/bulk-link-fix.js.map +1 -0
- package/dist/session-tools.d.ts +33 -0
- package/dist/session-tools.d.ts.map +1 -0
- package/dist/session-tools.js +211 -0
- package/dist/session-tools.js.map +1 -0
- package/dist/state.d.ts +54 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +176 -0
- package/dist/state.js.map +1 -0
- package/dist/store.d.ts +56 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +227 -0
- package/dist/store.js.map +1 -0
- package/dist/summarize.d.ts +56 -0
- package/dist/summarize.d.ts.map +1 -0
- package/dist/summarize.js +231 -0
- package/dist/summarize.js.map +1 -0
- package/dist/tag-registry.d.ts +56 -0
- package/dist/tag-registry.d.ts.map +1 -0
- package/dist/tag-registry.js +146 -0
- package/dist/tag-registry.js.map +1 -0
- package/dist/templates/prompts.d.ts +63 -0
- package/dist/templates/prompts.d.ts.map +1 -0
- package/dist/templates/prompts.js +260 -0
- package/dist/templates/prompts.js.map +1 -0
- package/dist/tools.d.ts +26 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +1033 -0
- package/dist/tools.js.map +1 -0
- package/dist/watcher.d.ts +25 -0
- package/dist/watcher.d.ts.map +1 -0
- package/dist/watcher.js +84 -0
- package/dist/watcher.js.map +1 -0
- package/dist/webui/client/dist/index.html +1885 -0
- package/dist/webui/server.d.ts +31 -0
- package/dist/webui/server.d.ts.map +1 -0
- package/dist/webui/server.js +566 -0
- package/dist/webui/server.js.map +1 -0
- package/dist/wiki.d.ts +103 -0
- package/dist/wiki.d.ts.map +1 -0
- package/dist/wiki.js +428 -0
- package/dist/wiki.js.map +1 -0
- package/package.json +142 -0
- package/scripts/gepa/__pycache__/dataset.cpython-313.pyc +0 -0
- package/scripts/gepa/__pycache__/lm_providers.cpython-313.pyc +0 -0
- package/scripts/gepa/__pycache__/metric.cpython-313.pyc +0 -0
- package/scripts/gepa/__pycache__/program.cpython-313.pyc +0 -0
- package/scripts/gepa/__pycache__/wiki_reader.cpython-313.pyc +0 -0
- package/scripts/gepa/dataset.py +192 -0
- package/scripts/gepa/lm_providers.py +472 -0
- package/scripts/gepa/metric.py +279 -0
- package/scripts/gepa/optimize.py +367 -0
- package/scripts/gepa/program.py +99 -0
- package/scripts/gepa/pyproject.toml +19 -0
- package/scripts/gepa/run-all.sh +53 -0
- package/scripts/gepa/uv.lock +2241 -0
- package/scripts/gepa/wiki_reader.py +113 -0
- package/skills/para/SKILL.md +77 -0
- package/skills/setup/SKILL.md +20 -0
- package/src/capture.ts +563 -0
- package/src/cli.ts +416 -0
- package/src/commands.ts +878 -0
- package/src/context.ts +390 -0
- package/src/daemon.ts +288 -0
- package/src/frontmatter.ts +219 -0
- package/src/gepa/index.ts +287 -0
- package/src/gepa/optimized/capture-prompt.txt +65 -0
- package/src/index.ts +384 -0
- package/src/ingest.ts +177 -0
- package/src/link-discovery.ts +170 -0
- package/src/link-utils.ts +169 -0
- package/src/lint.ts +1018 -0
- package/src/maintainer.ts +404 -0
- package/src/processor.ts +415 -0
- package/src/python/__pycache__/minimax_lm.cpython-312.pyc +0 -0
- package/src/python/minimax_lm.py +198 -0
- package/src/python/rlm_capture.py +215 -0
- package/src/query.ts +148 -0
- package/src/raw.ts +276 -0
- package/src/redact.ts +159 -0
- package/src/scope.ts +214 -0
- package/src/scripts/bulk-link-fix.ts +126 -0
- package/src/session-tools.ts +260 -0
- package/src/state.ts +271 -0
- package/src/store.ts +288 -0
- package/src/summarize.ts +273 -0
- package/src/tag-registry.ts +157 -0
- package/src/templates/index.md +17 -0
- package/src/templates/prompts.ts +274 -0
- package/src/templates/schema.md +116 -0
- package/src/tools.ts +1293 -0
- package/src/watcher.ts +101 -0
- package/src/webui/client/dist/index.html +1885 -0
- package/src/webui/server.ts +765 -0
- 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
|
+
}
|