packmind 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +136 -0
- package/dist/adapters/claude-code.js +67 -0
- package/dist/bin/packmind.js +13 -0
- package/dist/cli/backup-cmd.js +41 -0
- package/dist/cli/ctx.js +14 -0
- package/dist/cli/dashboard-cmd.js +34 -0
- package/dist/cli/doctor.js +45 -0
- package/dist/cli/index-cmd.js +15 -0
- package/dist/cli/index.js +68 -0
- package/dist/cli/init.js +83 -0
- package/dist/cli/insights-cmd.js +28 -0
- package/dist/cli/locate.js +15 -0
- package/dist/cli/maintain-cmd.js +39 -0
- package/dist/cli/mcp-cmd.js +5 -0
- package/dist/cli/policy-cmd.js +40 -0
- package/dist/cli/recall-cmd.js +18 -0
- package/dist/cli/registry.js +32 -0
- package/dist/cli/scan.js +18 -0
- package/dist/cli/solutions-cmd.js +24 -0
- package/dist/cli/status.js +28 -0
- package/dist/cli/update.js +73 -0
- package/dist/cost/estimator.js +21 -0
- package/dist/cost/exact.js +35 -0
- package/dist/cost/insights.js +80 -0
- package/dist/cost/ledger.js +47 -0
- package/dist/cost/pricing.js +27 -0
- package/dist/dashboard/server.js +128 -0
- package/dist/guard/path-guard.js +26 -0
- package/dist/guard/policy.js +0 -0
- package/dist/guard/secrets.js +29 -0
- package/dist/hooks/post-read.js +80 -0
- package/dist/hooks/post-write.js +107 -0
- package/dist/hooks/pre-read.js +94 -0
- package/dist/hooks/pre-write.js +101 -0
- package/dist/hooks/prompt-submit.js +37 -0
- package/dist/hooks/runtime.js +471 -0
- package/dist/hooks/session-start.js +72 -0
- package/dist/hooks/stop.js +69 -0
- package/dist/mcp/server.js +112 -0
- package/dist/mcp/tools.js +130 -0
- package/dist/recall/chunker.js +24 -0
- package/dist/recall/embedder.js +51 -0
- package/dist/recall/indexer.js +94 -0
- package/dist/recall/queue.js +24 -0
- package/dist/recall/store.js +48 -0
- package/dist/state/describe.js +63 -0
- package/dist/state/files.js +45 -0
- package/dist/state/formats.js +80 -0
- package/dist/state/maintain.js +25 -0
- package/dist/state/mapper.js +56 -0
- package/dist/state/project.js +33 -0
- package/dist/state/schema.js +47 -0
- package/dist/state/snapshot.js +69 -0
- package/dist/state/walk.js +75 -0
- package/dist/util/fs-atomic.js +106 -0
- package/dist/util/logger.js +17 -0
- package/dist/util/paths.js +20 -0
- package/dist/util/platform.js +10 -0
- package/package.json +72 -0
- package/src/templates/PACKMIND.md +42 -0
- package/src/templates/claude-md-snippet.md +9 -0
- package/src/templates/config.json +48 -0
- package/src/templates/dashboard.html +359 -0
- package/src/templates/gitattributes +2 -0
- package/src/templates/handoff.md +3 -0
- package/src/templates/hooks-package.json +4 -0
- package/src/templates/identity.md +5 -0
- package/src/templates/journal.md +3 -0
- package/src/templates/knowledge.md +12 -0
- package/src/templates/logo-dark.svg +23 -0
- package/src/templates/logo.svg +23 -0
- package/src/templates/map.md +3 -0
- package/src/templates/policy.json +11 -0
- package/src/templates/solutions.json +1 -0
- package/src/templates/usage.json +17 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
import { findRoot } from "../state/project.js";
|
|
5
|
+
import { makeContext, isInitialized, toolRecall, toolRemember, toolRecordSolution, toolProjectMap, toolUsageReport, toolInsights, toolHandoff, } from "./tools.js";
|
|
6
|
+
const TOOLS = [
|
|
7
|
+
{
|
|
8
|
+
name: "recall",
|
|
9
|
+
description: "Semantic search over PackMind's project memory (knowledge, journal, solutions, source). Use before investigating or re-deriving anything.",
|
|
10
|
+
inputSchema: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: { query: { type: "string", description: "What to search for" } },
|
|
13
|
+
required: ["query"],
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: "remember",
|
|
18
|
+
description: "Record a durable preference, decision, never-do rule, or note into project knowledge.",
|
|
19
|
+
inputSchema: {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
note: { type: "string" },
|
|
23
|
+
kind: { type: "string", enum: ["Preferences", "Decisions", "Never Do", "Notes"] },
|
|
24
|
+
},
|
|
25
|
+
required: ["note"],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: "record_solution",
|
|
30
|
+
description: "Record a bug and its fix so it is never re-investigated. Recording the same error again bumps its occurrence count.",
|
|
31
|
+
inputSchema: {
|
|
32
|
+
type: "object",
|
|
33
|
+
properties: {
|
|
34
|
+
error: { type: "string" },
|
|
35
|
+
cause: { type: "string" },
|
|
36
|
+
fix: { type: "string" },
|
|
37
|
+
file: { type: "string", description: "Optional file path the bug relates to" },
|
|
38
|
+
tags: { type: "array", items: { type: "string" } },
|
|
39
|
+
},
|
|
40
|
+
required: ["error"],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "project_map",
|
|
45
|
+
description: "List the project's files with descriptions and token estimates. Optional substring filter.",
|
|
46
|
+
inputSchema: { type: "object", properties: { filter: { type: "string" } } },
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "usage_report",
|
|
50
|
+
description: "Token usage and dollar cost for this project (lifetime).",
|
|
51
|
+
inputSchema: { type: "object", properties: {} },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "insights",
|
|
55
|
+
description: "Where tokens go and what PackMind saved: cost, estimated savings, map coverage, heaviest files, and upkeep notes.",
|
|
56
|
+
inputSchema: { type: "object", properties: {} },
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "handoff",
|
|
60
|
+
description: "Get or set the session handoff note ('where we are / what's next').",
|
|
61
|
+
inputSchema: {
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: {
|
|
64
|
+
action: { type: "string", enum: ["get", "set"] },
|
|
65
|
+
content: { type: "string" },
|
|
66
|
+
},
|
|
67
|
+
required: ["action"],
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
function text(s) {
|
|
72
|
+
return { content: [{ type: "text", text: s }] };
|
|
73
|
+
}
|
|
74
|
+
async function main() {
|
|
75
|
+
const projectRoot = findRoot();
|
|
76
|
+
const server = new Server({ name: "packmind", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
77
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
78
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
79
|
+
if (!isInitialized(projectRoot)) {
|
|
80
|
+
return text("PackMind is not initialized in this project. Run `packmind init`.");
|
|
81
|
+
}
|
|
82
|
+
const ctx = makeContext(projectRoot);
|
|
83
|
+
const a = (req.params.arguments ?? {});
|
|
84
|
+
try {
|
|
85
|
+
switch (req.params.name) {
|
|
86
|
+
case "recall":
|
|
87
|
+
return text(await toolRecall(ctx, String(a.query ?? "")));
|
|
88
|
+
case "remember":
|
|
89
|
+
return text(toolRemember(ctx, String(a.note ?? ""), a.kind));
|
|
90
|
+
case "record_solution":
|
|
91
|
+
return text(toolRecordSolution(ctx, { error: String(a.error ?? ""), cause: a.cause, fix: a.fix, file: a.file, tags: a.tags }));
|
|
92
|
+
case "project_map":
|
|
93
|
+
return text(toolProjectMap(ctx, a.filter));
|
|
94
|
+
case "usage_report":
|
|
95
|
+
return text(toolUsageReport(ctx));
|
|
96
|
+
case "insights":
|
|
97
|
+
return text(toolInsights(ctx));
|
|
98
|
+
case "handoff":
|
|
99
|
+
return text(toolHandoff(ctx, a.action === "set" ? "set" : "get", a.content));
|
|
100
|
+
default:
|
|
101
|
+
return text(`Unknown tool: ${req.params.name}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
return text(`PackMind tool error: ${err instanceof Error ? err.message : String(err)}`);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
await server.connect(new StdioServerTransport());
|
|
109
|
+
}
|
|
110
|
+
export function runMcpServer() {
|
|
111
|
+
return main();
|
|
112
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { brain } from "../state/files.js";
|
|
3
|
+
import { loadConfig } from "../state/schema.js";
|
|
4
|
+
import { readJsonOr, writeJson, appendLine, readTextOr, writeText } from "../util/fs-atomic.js";
|
|
5
|
+
import { parseMap } from "../state/formats.js";
|
|
6
|
+
import { readLedger, totalCost } from "../cost/ledger.js";
|
|
7
|
+
import { recall as recallSearch } from "../recall/indexer.js";
|
|
8
|
+
import { computeInsights } from "../cost/insights.js";
|
|
9
|
+
import { LocalEmbedder } from "../recall/embedder.js";
|
|
10
|
+
import { enqueue } from "../recall/queue.js";
|
|
11
|
+
export function makeContext(projectRoot) {
|
|
12
|
+
const config = loadConfig(brain(projectRoot).config);
|
|
13
|
+
return { projectRoot, config, embedder: new LocalEmbedder(config.recall.embedModel) };
|
|
14
|
+
}
|
|
15
|
+
export async function toolRecall(ctx, query) {
|
|
16
|
+
if (!ctx.config.recall.enabled)
|
|
17
|
+
return "Recall is disabled in config.";
|
|
18
|
+
const hits = await recallSearch(ctx.projectRoot, ctx.config, ctx.embedder, query);
|
|
19
|
+
if (hits.length === 0)
|
|
20
|
+
return "No relevant memory found. Index may be empty — run `packmind index`.";
|
|
21
|
+
return hits
|
|
22
|
+
.map((h, i) => `${i + 1}. [${h.kind} · ${h.source} · score ${h.score.toFixed(2)}]\n${h.text.slice(0, 600)}`)
|
|
23
|
+
.join("\n\n");
|
|
24
|
+
}
|
|
25
|
+
export function toolRemember(ctx, note, kind = "Notes") {
|
|
26
|
+
const heading = ["Preferences", "Decisions", "Never Do", "Notes"].includes(kind) ? kind : "Notes";
|
|
27
|
+
const file = brain(ctx.projectRoot).knowledge;
|
|
28
|
+
const existing = readTextOr(file);
|
|
29
|
+
// Append under the heading if present; otherwise create it.
|
|
30
|
+
if (new RegExp(`^##\\s+${heading}\\b`, "m").test(existing)) {
|
|
31
|
+
appendLine(file, `\n<!-- ${heading} -->\n- ${new Date().toISOString().slice(0, 10)}: ${note}\n`);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
appendLine(file, `\n## ${heading}\n\n- ${new Date().toISOString().slice(0, 10)}: ${note}\n`);
|
|
35
|
+
}
|
|
36
|
+
enqueue(ctx.projectRoot, ".packmind/knowledge.md");
|
|
37
|
+
return `Recorded under "${heading}".`;
|
|
38
|
+
}
|
|
39
|
+
const normalizeError = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
40
|
+
export function toolRecordSolution(ctx, args) {
|
|
41
|
+
const path = brain(ctx.projectRoot).solutions;
|
|
42
|
+
const list = readJsonOr(path, []);
|
|
43
|
+
const now = new Date().toISOString();
|
|
44
|
+
// De-dupe: if we've seen this error before, bump its occurrence count instead
|
|
45
|
+
// of appending a near-duplicate (keeps the bug memory high-signal).
|
|
46
|
+
const key = normalizeError(args.error);
|
|
47
|
+
const existing = list.find((s) => normalizeError(s.error ?? "") === key);
|
|
48
|
+
if (existing) {
|
|
49
|
+
existing.occurrences = (existing.occurrences ?? 1) + 1;
|
|
50
|
+
existing.lastSeen = now;
|
|
51
|
+
if (args.fix && !existing.fix)
|
|
52
|
+
existing.fix = args.fix;
|
|
53
|
+
if (args.file && !existing.file)
|
|
54
|
+
existing.file = args.file;
|
|
55
|
+
for (const tag of args.tags ?? [])
|
|
56
|
+
if (!existing.tags?.includes(tag))
|
|
57
|
+
(existing.tags ??= []).push(tag);
|
|
58
|
+
writeJson(path, list);
|
|
59
|
+
enqueue(ctx.projectRoot, ".packmind/solutions.json");
|
|
60
|
+
return `Updated existing solution ${existing.id} — now seen ${existing.occurrences} times.`;
|
|
61
|
+
}
|
|
62
|
+
const entry = {
|
|
63
|
+
id: `sol-${Date.now()}`,
|
|
64
|
+
at: now,
|
|
65
|
+
lastSeen: now,
|
|
66
|
+
occurrences: 1,
|
|
67
|
+
error: args.error,
|
|
68
|
+
cause: args.cause ?? "",
|
|
69
|
+
fix: args.fix ?? "",
|
|
70
|
+
file: args.file ?? "",
|
|
71
|
+
tags: args.tags ?? [],
|
|
72
|
+
};
|
|
73
|
+
list.push(entry);
|
|
74
|
+
writeJson(path, list);
|
|
75
|
+
enqueue(ctx.projectRoot, ".packmind/solutions.json");
|
|
76
|
+
return `Recorded solution ${entry.id}.`;
|
|
77
|
+
}
|
|
78
|
+
export function toolProjectMap(ctx, filter) {
|
|
79
|
+
const map = parseMap(readTextOr(brain(ctx.projectRoot).map));
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const [section, entries] of map) {
|
|
82
|
+
const rows = entries
|
|
83
|
+
.filter((e) => !filter || (section + e.file).toLowerCase().includes(filter.toLowerCase()))
|
|
84
|
+
.map((e) => ` ${section}${e.file} — ${e.description || "?"} (~${e.tokens} tok)`);
|
|
85
|
+
if (rows.length)
|
|
86
|
+
out.push(`${section}\n${rows.join("\n")}`);
|
|
87
|
+
}
|
|
88
|
+
return out.length ? out.join("\n") : "Map is empty — run `packmind scan`.";
|
|
89
|
+
}
|
|
90
|
+
export function toolInsights(ctx) {
|
|
91
|
+
const r = computeInsights(ctx.projectRoot, ctx.config);
|
|
92
|
+
const lines = [
|
|
93
|
+
`Cost so far: $${r.totalCost.toFixed(4)} (${r.inputTokens} in / ${r.outputTokens} out)`,
|
|
94
|
+
`Estimated saved: $${r.estCostSaved.toFixed(4)} (~${r.estTokensSaved} tokens; ${r.reReadsAvoided} re-reads avoided)`,
|
|
95
|
+
`Map coverage: ${r.mapCoverage === null ? "n/a" : Math.round(r.mapCoverage * 100) + "%"}`,
|
|
96
|
+
];
|
|
97
|
+
if (r.topFiles.length) {
|
|
98
|
+
lines.push("Heaviest files:");
|
|
99
|
+
for (const f of r.topFiles)
|
|
100
|
+
lines.push(` ${f.file} — ~${f.tokens} tok ($${f.cost.toFixed(4)})`);
|
|
101
|
+
}
|
|
102
|
+
for (const f of r.flags)
|
|
103
|
+
lines.push(`[${f.level}] ${f.title}: ${f.detail}`);
|
|
104
|
+
return lines.join("\n");
|
|
105
|
+
}
|
|
106
|
+
export function toolUsageReport(ctx) {
|
|
107
|
+
const ledger = readLedger(ctx.projectRoot, ctx.config.model);
|
|
108
|
+
const t = ledger.totals;
|
|
109
|
+
return [
|
|
110
|
+
`Model: ${ledger.model}`,
|
|
111
|
+
`Sessions: ${t.sessions}`,
|
|
112
|
+
`Reads: ${t.reads} (deduped ${t.dedupedReads}, map hits ${t.mapHits})`,
|
|
113
|
+
`Writes: ${t.writes}`,
|
|
114
|
+
`Tokens: ${t.inputTokens.toLocaleString()} in / ${t.outputTokens.toLocaleString()} out`,
|
|
115
|
+
`Cost: $${totalCost(ledger).toFixed(4)} ($${t.inputCost.toFixed(4)} in / $${t.outputCost.toFixed(4)} out)`,
|
|
116
|
+
].join("\n");
|
|
117
|
+
}
|
|
118
|
+
export function toolHandoff(ctx, action, content) {
|
|
119
|
+
const file = brain(ctx.projectRoot).handoff;
|
|
120
|
+
if (action === "set") {
|
|
121
|
+
writeText(file, `# Session Handoff\n\n_Updated ${new Date().toISOString()}_\n\n${content ?? ""}\n`);
|
|
122
|
+
return "Handoff updated.";
|
|
123
|
+
}
|
|
124
|
+
const text = readTextOr(file).trim();
|
|
125
|
+
return text || "No handoff recorded yet.";
|
|
126
|
+
}
|
|
127
|
+
/** True if the project has been initialized. */
|
|
128
|
+
export function isInitialized(projectRoot) {
|
|
129
|
+
return fs.existsSync(brain(projectRoot).config);
|
|
130
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split text into overlapping windows of roughly `size` characters, breaking on
|
|
3
|
+
* line boundaries where possible so chunks stay semantically coherent.
|
|
4
|
+
*/
|
|
5
|
+
export function chunkText(text, source, kind, size = 1200) {
|
|
6
|
+
const clean = text.trim();
|
|
7
|
+
if (!clean)
|
|
8
|
+
return [];
|
|
9
|
+
if (clean.length <= size)
|
|
10
|
+
return [{ source, kind, text: clean }];
|
|
11
|
+
const chunks = [];
|
|
12
|
+
const lines = clean.split(/\r?\n/);
|
|
13
|
+
let buf = "";
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
if (buf.length + line.length + 1 > size && buf) {
|
|
16
|
+
chunks.push({ source, kind, text: buf.trim() });
|
|
17
|
+
buf = "";
|
|
18
|
+
}
|
|
19
|
+
buf += line + "\n";
|
|
20
|
+
}
|
|
21
|
+
if (buf.trim())
|
|
22
|
+
chunks.push({ source, kind, text: buf.trim() });
|
|
23
|
+
return chunks;
|
|
24
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { userRoot } from "../util/platform.js";
|
|
2
|
+
/**
|
|
3
|
+
* Local, offline embedder backed by transformers.js (WASM). The model is
|
|
4
|
+
* downloaded once and cached under ~/.packmind/models — no API key, no network
|
|
5
|
+
* after the first run, nothing leaves the machine.
|
|
6
|
+
*/
|
|
7
|
+
export class LocalEmbedder {
|
|
8
|
+
model;
|
|
9
|
+
pipe = null;
|
|
10
|
+
dims = 384;
|
|
11
|
+
constructor(model = "Xenova/all-MiniLM-L6-v2") {
|
|
12
|
+
this.model = model;
|
|
13
|
+
}
|
|
14
|
+
dimensions() {
|
|
15
|
+
return this.dims;
|
|
16
|
+
}
|
|
17
|
+
async ensure() {
|
|
18
|
+
if (this.pipe)
|
|
19
|
+
return this.pipe;
|
|
20
|
+
let mod;
|
|
21
|
+
try {
|
|
22
|
+
// Resolved at runtime only — the package is an OPTIONAL dependency, so a
|
|
23
|
+
// non-literal specifier keeps `tsc` from requiring it at compile time
|
|
24
|
+
// (it isn't installed in CI's --no-optional build).
|
|
25
|
+
const pkg = "@xenova/transformers";
|
|
26
|
+
mod = await import(pkg);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new Error("Semantic recall needs the optional '@xenova/transformers' package. Install it with:\n" +
|
|
30
|
+
" npm install -g @xenova/transformers\n" +
|
|
31
|
+
"(or set recall.enabled = false in .packmind/config.json to disable recall).");
|
|
32
|
+
}
|
|
33
|
+
mod.env.cacheDir = `${userRoot()}/models`;
|
|
34
|
+
mod.env.allowLocalModels = true;
|
|
35
|
+
this.pipe = await mod.pipeline("feature-extraction", this.model);
|
|
36
|
+
return this.pipe;
|
|
37
|
+
}
|
|
38
|
+
async embed(texts) {
|
|
39
|
+
if (texts.length === 0)
|
|
40
|
+
return [];
|
|
41
|
+
const pipe = await this.ensure();
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const text of texts) {
|
|
44
|
+
const tensor = await pipe(text, { pooling: "mean", normalize: true });
|
|
45
|
+
const vec = Array.from(tensor.data);
|
|
46
|
+
this.dims = vec.length;
|
|
47
|
+
out.push(vec);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { walkProject } from "../state/walk.js";
|
|
4
|
+
import { brain } from "../state/files.js";
|
|
5
|
+
import { chunkText } from "./chunker.js";
|
|
6
|
+
import { VectorStore } from "./store.js";
|
|
7
|
+
import { drainQueue } from "./queue.js";
|
|
8
|
+
/** Brain files that are worth embedding as searchable knowledge. */
|
|
9
|
+
const BRAIN_SOURCES = [
|
|
10
|
+
{ file: "knowledge.md", kind: "knowledge" },
|
|
11
|
+
{ file: "journal.md", kind: "journal" },
|
|
12
|
+
{ file: "solutions.json", kind: "solution" },
|
|
13
|
+
{ file: "handoff.md", kind: "handoff" },
|
|
14
|
+
];
|
|
15
|
+
function collectChunks(projectRoot, config, sources) {
|
|
16
|
+
const chunks = [];
|
|
17
|
+
const want = sources ? new Set(sources) : null;
|
|
18
|
+
for (const { file, kind } of BRAIN_SOURCES) {
|
|
19
|
+
const rel = `.packmind/${file}`;
|
|
20
|
+
if (want && !want.has(rel))
|
|
21
|
+
continue;
|
|
22
|
+
try {
|
|
23
|
+
const text = fs.readFileSync(path.join(projectRoot, ".packmind", file), "utf8");
|
|
24
|
+
chunks.push(...chunkText(text, rel, kind, config.recall.chunkChars));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
/* missing brain file */
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
for (const { abs, rel } of walkProject(projectRoot, config)) {
|
|
31
|
+
if (want && !want.has(rel))
|
|
32
|
+
continue;
|
|
33
|
+
try {
|
|
34
|
+
const text = fs.readFileSync(abs, "utf8");
|
|
35
|
+
chunks.push(...chunkText(text, rel, "code", config.recall.chunkChars));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
/* unreadable */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return chunks;
|
|
42
|
+
}
|
|
43
|
+
async function embedChunks(embedder, chunks) {
|
|
44
|
+
if (chunks.length === 0)
|
|
45
|
+
return [];
|
|
46
|
+
const vectors = await embedder.embed(chunks.map((c) => c.text));
|
|
47
|
+
return chunks.map((c, i) => ({
|
|
48
|
+
id: `${c.source}#${i}`,
|
|
49
|
+
source: c.source,
|
|
50
|
+
kind: c.kind,
|
|
51
|
+
text: c.text,
|
|
52
|
+
vector: vectors[i],
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
/** Full (re)index of the project into the vector store. */
|
|
56
|
+
export async function buildIndex(projectRoot, config, embedder) {
|
|
57
|
+
const chunks = collectChunks(projectRoot, config, null);
|
|
58
|
+
const records = await embedChunks(embedder, chunks);
|
|
59
|
+
const store = new VectorStore(brain(projectRoot).vectors, config.recall.embedModel);
|
|
60
|
+
// Group by source so upsertBySource replaces cleanly.
|
|
61
|
+
const bySource = new Set(records.map((r) => r.source));
|
|
62
|
+
for (const s of store.sources())
|
|
63
|
+
if (!bySource.has(s))
|
|
64
|
+
store.removeSource(s);
|
|
65
|
+
store.upsertBySource(records);
|
|
66
|
+
store.save();
|
|
67
|
+
return store.size();
|
|
68
|
+
}
|
|
69
|
+
/** Incrementally embed whatever the hooks queued; drop deleted sources. */
|
|
70
|
+
export async function refreshFromQueue(projectRoot, config, embedder) {
|
|
71
|
+
const queued = drainQueue(projectRoot);
|
|
72
|
+
if (queued.length === 0)
|
|
73
|
+
return 0;
|
|
74
|
+
const store = new VectorStore(brain(projectRoot).vectors, config.recall.embedModel);
|
|
75
|
+
const present = queued.filter((rel) => fs.existsSync(path.join(projectRoot, rel)) || rel.startsWith(".packmind/"));
|
|
76
|
+
for (const rel of queued)
|
|
77
|
+
if (!present.includes(rel))
|
|
78
|
+
store.removeSource(rel);
|
|
79
|
+
const chunks = collectChunks(projectRoot, config, present);
|
|
80
|
+
const records = await embedChunks(embedder, chunks);
|
|
81
|
+
store.upsertBySource(records);
|
|
82
|
+
store.save();
|
|
83
|
+
return records.length;
|
|
84
|
+
}
|
|
85
|
+
export async function recall(projectRoot, config, embedder, query) {
|
|
86
|
+
await refreshFromQueue(projectRoot, config, embedder);
|
|
87
|
+
const store = new VectorStore(brain(projectRoot).vectors, config.recall.embedModel);
|
|
88
|
+
if (store.size() === 0)
|
|
89
|
+
return [];
|
|
90
|
+
const [qv] = await embedder.embed([query]);
|
|
91
|
+
return store
|
|
92
|
+
.search(qv, config.recall.topK)
|
|
93
|
+
.map((r) => ({ source: r.source, kind: r.kind, text: r.text, score: r.score }));
|
|
94
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readJsonOr, writeJson } from "../util/fs-atomic.js";
|
|
2
|
+
import { brain } from "../state/files.js";
|
|
3
|
+
/**
|
|
4
|
+
* The index queue records source paths the hooks touched so the indexer can
|
|
5
|
+
* (re)embed them lazily. Hooks have no heavy dependencies, so they only enqueue;
|
|
6
|
+
* `packmind index` and the MCP server drain the queue.
|
|
7
|
+
*/
|
|
8
|
+
export function enqueue(projectRoot, relPath) {
|
|
9
|
+
const q = readJsonOr(brain(projectRoot).queue, []);
|
|
10
|
+
if (!q.includes(relPath)) {
|
|
11
|
+
q.push(relPath);
|
|
12
|
+
writeJson(brain(projectRoot).queue, q);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function drainQueue(projectRoot) {
|
|
16
|
+
const path = brain(projectRoot).queue;
|
|
17
|
+
const q = readJsonOr(path, []);
|
|
18
|
+
if (q.length)
|
|
19
|
+
writeJson(path, []);
|
|
20
|
+
return q;
|
|
21
|
+
}
|
|
22
|
+
export function peekQueue(projectRoot) {
|
|
23
|
+
return readJsonOr(brain(projectRoot).queue, []);
|
|
24
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readJsonOr, writeJson } from "../util/fs-atomic.js";
|
|
2
|
+
export function cosine(a, b) {
|
|
3
|
+
let dot = 0;
|
|
4
|
+
let na = 0;
|
|
5
|
+
let nb = 0;
|
|
6
|
+
const n = Math.min(a.length, b.length);
|
|
7
|
+
for (let i = 0; i < n; i++) {
|
|
8
|
+
dot += a[i] * b[i];
|
|
9
|
+
na += a[i] * a[i];
|
|
10
|
+
nb += b[i] * b[i];
|
|
11
|
+
}
|
|
12
|
+
if (na === 0 || nb === 0)
|
|
13
|
+
return 0;
|
|
14
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
15
|
+
}
|
|
16
|
+
/** A flat-file vector index. Small projects fit comfortably in memory. */
|
|
17
|
+
export class VectorStore {
|
|
18
|
+
file;
|
|
19
|
+
data;
|
|
20
|
+
constructor(file, model = "unknown") {
|
|
21
|
+
this.file = file;
|
|
22
|
+
this.data = readJsonOr(file, { version: 1, model, records: [] });
|
|
23
|
+
}
|
|
24
|
+
/** Replace all records whose source matches one being upserted. */
|
|
25
|
+
upsertBySource(records) {
|
|
26
|
+
const sources = new Set(records.map((r) => r.source));
|
|
27
|
+
this.data.records = this.data.records.filter((r) => !sources.has(r.source));
|
|
28
|
+
this.data.records.push(...records);
|
|
29
|
+
}
|
|
30
|
+
removeSource(source) {
|
|
31
|
+
this.data.records = this.data.records.filter((r) => r.source !== source);
|
|
32
|
+
}
|
|
33
|
+
sources() {
|
|
34
|
+
return new Set(this.data.records.map((r) => r.source));
|
|
35
|
+
}
|
|
36
|
+
search(queryVector, topK) {
|
|
37
|
+
return this.data.records
|
|
38
|
+
.map((r) => ({ ...r, score: cosine(queryVector, r.vector) }))
|
|
39
|
+
.sort((a, b) => b.score - a.score)
|
|
40
|
+
.slice(0, topK);
|
|
41
|
+
}
|
|
42
|
+
size() {
|
|
43
|
+
return this.data.records.length;
|
|
44
|
+
}
|
|
45
|
+
save() {
|
|
46
|
+
writeJson(this.file, this.data);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
const KNOWN = {
|
|
3
|
+
"package.json": "Node package manifest",
|
|
4
|
+
"tsconfig.json": "TypeScript compiler config",
|
|
5
|
+
"Dockerfile": "Container build recipe",
|
|
6
|
+
"docker-compose.yml": "Docker Compose services",
|
|
7
|
+
".gitignore": "Git ignore rules",
|
|
8
|
+
"Makefile": "Make build targets",
|
|
9
|
+
"README.md": "Project readme",
|
|
10
|
+
"LICENSE": "License text",
|
|
11
|
+
"pyproject.toml": "Python project config",
|
|
12
|
+
"Cargo.toml": "Rust crate manifest",
|
|
13
|
+
"go.mod": "Go module definition",
|
|
14
|
+
"vite.config.ts": "Vite build config",
|
|
15
|
+
"vitest.config.ts": "Vitest test config",
|
|
16
|
+
};
|
|
17
|
+
const COMMENT = /^\s*(?:\/\/+|#+|--|;|\*)\s?(.+)$/;
|
|
18
|
+
/**
|
|
19
|
+
* Derive a one-line description of a file from its first 8KB. Original
|
|
20
|
+
* heuristic: known names first, then a leading doc/comment, a JSON description,
|
|
21
|
+
* or the first declared symbol. Returns "" when nothing meaningful is found.
|
|
22
|
+
*/
|
|
23
|
+
export function describeFile(filePath, content) {
|
|
24
|
+
const base = path.basename(filePath);
|
|
25
|
+
if (KNOWN[base])
|
|
26
|
+
return KNOWN[base];
|
|
27
|
+
const head = content.slice(0, 8192);
|
|
28
|
+
const ext = path.extname(base).toLowerCase();
|
|
29
|
+
if (ext === ".md" || ext === ".mdx") {
|
|
30
|
+
const h = head.match(/^#{1,3}\s+(.+)$/m);
|
|
31
|
+
if (h)
|
|
32
|
+
return cap(h[1]);
|
|
33
|
+
}
|
|
34
|
+
if (ext === ".json") {
|
|
35
|
+
try {
|
|
36
|
+
const j = JSON.parse(content);
|
|
37
|
+
if (typeof j.description === "string" && j.description)
|
|
38
|
+
return cap(j.description);
|
|
39
|
+
if (typeof j.name === "string" && j.name)
|
|
40
|
+
return cap(String(j.name));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
/* not valid JSON */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const raw of head.split(/\r?\n/)) {
|
|
47
|
+
const line = raw.trim();
|
|
48
|
+
if (!line)
|
|
49
|
+
continue;
|
|
50
|
+
const c = line.match(COMMENT);
|
|
51
|
+
if (c && c[1].trim().length > 3 && !/^[=*-]+$/.test(c[1].trim()))
|
|
52
|
+
return cap(c[1]);
|
|
53
|
+
break; // only consider the very first non-blank line as a header comment
|
|
54
|
+
}
|
|
55
|
+
const sym = head.match(/\b(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|const|interface|type|def|struct|enum|fn)\s+([A-Za-z_$][\w$]*)/);
|
|
56
|
+
if (sym)
|
|
57
|
+
return cap(`Defines ${sym[1]}`);
|
|
58
|
+
return "";
|
|
59
|
+
}
|
|
60
|
+
function cap(s, max = 90) {
|
|
61
|
+
const clean = s.replace(/\s+/g, " ").trim();
|
|
62
|
+
return clean.length > max ? clean.slice(0, max - 1) + "…" : clean;
|
|
63
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { stateFile } from "../util/paths.js";
|
|
2
|
+
import { readJsonOr, writeJson } from "../util/fs-atomic.js";
|
|
3
|
+
/** Resolve the standard brain-file paths for a project. */
|
|
4
|
+
export function brain(projectRoot) {
|
|
5
|
+
return {
|
|
6
|
+
dir: stateFile(projectRoot),
|
|
7
|
+
protocol: stateFile(projectRoot, "PACKMIND.md"),
|
|
8
|
+
config: stateFile(projectRoot, "config.json"),
|
|
9
|
+
map: stateFile(projectRoot, "map.md"),
|
|
10
|
+
knowledge: stateFile(projectRoot, "knowledge.md"),
|
|
11
|
+
journal: stateFile(projectRoot, "journal.md"),
|
|
12
|
+
solutions: stateFile(projectRoot, "solutions.json"),
|
|
13
|
+
usage: stateFile(projectRoot, "usage.json"),
|
|
14
|
+
handoff: stateFile(projectRoot, "handoff.md"),
|
|
15
|
+
policy: stateFile(projectRoot, "policy.json"),
|
|
16
|
+
identity: stateFile(projectRoot, "identity.md"),
|
|
17
|
+
session: stateFile(projectRoot, "state", "session.json"),
|
|
18
|
+
recallDir: stateFile(projectRoot, "recall"),
|
|
19
|
+
queue: stateFile(projectRoot, "recall", "queue.json"),
|
|
20
|
+
vectors: stateFile(projectRoot, "recall", "vectors.json"),
|
|
21
|
+
hooksDir: stateFile(projectRoot, "hooks"),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function emptySession(id) {
|
|
25
|
+
return {
|
|
26
|
+
id,
|
|
27
|
+
started: new Date().toISOString(),
|
|
28
|
+
reads: {},
|
|
29
|
+
writes: [],
|
|
30
|
+
editCounts: {},
|
|
31
|
+
inputTokens: 0,
|
|
32
|
+
outputTokens: 0,
|
|
33
|
+
inputCost: 0,
|
|
34
|
+
outputCost: 0,
|
|
35
|
+
mapHits: 0,
|
|
36
|
+
mapMisses: 0,
|
|
37
|
+
dedupedReads: 0,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function readSession(projectRoot) {
|
|
41
|
+
return readJsonOr(brain(projectRoot).session, null);
|
|
42
|
+
}
|
|
43
|
+
export function writeSession(projectRoot, s) {
|
|
44
|
+
writeJson(brain(projectRoot).session, s);
|
|
45
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parsers and serializers for PackMind's human-readable brain files.
|
|
3
|
+
*
|
|
4
|
+
* Every split is CRLF-tolerant (`/\r?\n/`) so files authored on Windows or with
|
|
5
|
+
* `core.autocrlf=true` round-trip cleanly. This module is the single source of
|
|
6
|
+
* truth for these formats; the standalone hook runtime keeps a mirror that is
|
|
7
|
+
* verified identical by a parity test.
|
|
8
|
+
*/
|
|
9
|
+
export function lines(text) {
|
|
10
|
+
return text.split(/\r?\n/);
|
|
11
|
+
}
|
|
12
|
+
const SECTION = /^##\s+(.+?)\s*$/;
|
|
13
|
+
// `- `name` · ~123 tok[ · $0.0012] — description`
|
|
14
|
+
const ENTRY = /^-\s+`([^`]+)`\s+·\s+~(\d+)\s+tok(?:\s+·\s+\$([\d.]+))?(?:\s+—\s+(.*\S))?\s*$/;
|
|
15
|
+
export function parseMap(text) {
|
|
16
|
+
const out = new Map();
|
|
17
|
+
let section = "";
|
|
18
|
+
for (const line of lines(text)) {
|
|
19
|
+
const s = line.match(SECTION);
|
|
20
|
+
if (s) {
|
|
21
|
+
section = s[1].trim();
|
|
22
|
+
if (!out.has(section))
|
|
23
|
+
out.set(section, []);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (!section)
|
|
27
|
+
continue;
|
|
28
|
+
const e = line.match(ENTRY);
|
|
29
|
+
if (e) {
|
|
30
|
+
out.get(section).push({
|
|
31
|
+
file: e[1],
|
|
32
|
+
tokens: parseInt(e[2], 10),
|
|
33
|
+
cost: e[3] ? Number(e[3]) : undefined,
|
|
34
|
+
description: e[4] ? e[4].trim() : "",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
export function serializeMap(sections, meta) {
|
|
41
|
+
const out = [
|
|
42
|
+
"# Project Map",
|
|
43
|
+
"",
|
|
44
|
+
`_Maintained by PackMind · ${meta.fileCount} files · updated ${meta.updated}_`,
|
|
45
|
+
"",
|
|
46
|
+
];
|
|
47
|
+
for (const key of [...sections.keys()].sort()) {
|
|
48
|
+
const entries = sections.get(key);
|
|
49
|
+
if (entries.length === 0)
|
|
50
|
+
continue;
|
|
51
|
+
out.push(`## ${key}`, "");
|
|
52
|
+
for (const e of [...entries].sort((a, b) => a.file.localeCompare(b.file))) {
|
|
53
|
+
const cost = e.cost && e.cost > 0 ? ` · $${e.cost.toFixed(4)}` : "";
|
|
54
|
+
const desc = e.description ? ` — ${e.description}` : "";
|
|
55
|
+
out.push(`- \`${e.file}\` · ~${e.tokens} tok${cost}${desc}`);
|
|
56
|
+
}
|
|
57
|
+
out.push("");
|
|
58
|
+
}
|
|
59
|
+
return out.join("\n");
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Extract entries under a `## Never Do` heading in knowledge.md so the write
|
|
63
|
+
* guard can warn before a known-bad pattern is reintroduced. CRLF-safe.
|
|
64
|
+
*/
|
|
65
|
+
export function parseNeverDo(knowledge) {
|
|
66
|
+
const result = [];
|
|
67
|
+
let active = false;
|
|
68
|
+
for (const line of lines(knowledge)) {
|
|
69
|
+
if (/^##\s+/.test(line)) {
|
|
70
|
+
active = /^##\s+Never\s*Do\b/i.test(line);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!active)
|
|
74
|
+
continue;
|
|
75
|
+
const m = line.match(/^[-*]\s+(?:\[[\d-]+\]\s*)?(.+?)\s*$/);
|
|
76
|
+
if (m)
|
|
77
|
+
result.push(m[1]);
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|