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
package/dist/cli/init.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { findRoot, isHome } from "../state/project.js";
|
|
5
|
+
import { brain } from "../state/files.js";
|
|
6
|
+
import { loadConfig } from "../state/schema.js";
|
|
7
|
+
import { scanProject } from "../state/mapper.js";
|
|
8
|
+
import { registerHooks, registerMcp } from "../adapters/claude-code.js";
|
|
9
|
+
import { ensureDir } from "../util/paths.js";
|
|
10
|
+
import { TEMPLATES_DIR, HOOKS_DIST_DIR, pkgVersion } from "./locate.js";
|
|
11
|
+
import { registerProject } from "./registry.js";
|
|
12
|
+
const CREATE_IF_MISSING = [
|
|
13
|
+
"config.json", "knowledge.md", "journal.md", "map.md", "handoff.md",
|
|
14
|
+
"solutions.json", "usage.json", "identity.md", "policy.json",
|
|
15
|
+
];
|
|
16
|
+
const ALWAYS_OVERWRITE = ["PACKMIND.md"];
|
|
17
|
+
const HOOK_SCRIPTS = [
|
|
18
|
+
"runtime.js", "session-start.js", "prompt-submit.js", "pre-read.js",
|
|
19
|
+
"post-read.js", "pre-write.js", "post-write.js", "stop.js",
|
|
20
|
+
];
|
|
21
|
+
function copy(src, dest) {
|
|
22
|
+
if (fs.existsSync(src))
|
|
23
|
+
fs.writeFileSync(dest, fs.readFileSync(src));
|
|
24
|
+
}
|
|
25
|
+
function seed(name, dir, overwrite) {
|
|
26
|
+
const dest = path.join(dir, name);
|
|
27
|
+
if (!overwrite && fs.existsSync(dest))
|
|
28
|
+
return;
|
|
29
|
+
let content = fs.readFileSync(path.join(TEMPLATES_DIR, name), "utf8");
|
|
30
|
+
if (name === "usage.json")
|
|
31
|
+
content = content.replace('"createdAt": ""', `"createdAt": "${new Date().toISOString()}"`);
|
|
32
|
+
fs.writeFileSync(dest, content);
|
|
33
|
+
}
|
|
34
|
+
function wireClaudeMd(projectRoot, claudeMdRel) {
|
|
35
|
+
const target = path.join(projectRoot, claudeMdRel);
|
|
36
|
+
const snippet = fs.readFileSync(path.join(TEMPLATES_DIR, "claude-md-snippet.md"), "utf8");
|
|
37
|
+
let existing = "";
|
|
38
|
+
try {
|
|
39
|
+
existing = fs.readFileSync(target, "utf8");
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
/* missing */
|
|
43
|
+
}
|
|
44
|
+
if (existing.includes("PACKMIND:START"))
|
|
45
|
+
return;
|
|
46
|
+
ensureDir(path.dirname(target));
|
|
47
|
+
fs.writeFileSync(target, existing ? existing.replace(/\s*$/, "\n\n") + snippet : snippet);
|
|
48
|
+
}
|
|
49
|
+
export function runInit() {
|
|
50
|
+
const projectRoot = findRoot();
|
|
51
|
+
if (isHome(projectRoot)) {
|
|
52
|
+
console.error(chalk.red("✗ Refusing to initialize in your home directory. Run inside a project."));
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
const b = brain(projectRoot);
|
|
56
|
+
ensureDir(b.hooksDir);
|
|
57
|
+
ensureDir(path.join(b.dir, "state"));
|
|
58
|
+
ensureDir(b.recallDir);
|
|
59
|
+
const fresh = !fs.existsSync(b.config);
|
|
60
|
+
for (const f of CREATE_IF_MISSING)
|
|
61
|
+
seed(f, b.dir, false);
|
|
62
|
+
for (const f of ALWAYS_OVERWRITE)
|
|
63
|
+
seed(f, b.dir, true);
|
|
64
|
+
for (const script of HOOK_SCRIPTS)
|
|
65
|
+
copy(path.join(HOOKS_DIST_DIR, script), path.join(b.hooksDir, script));
|
|
66
|
+
copy(path.join(TEMPLATES_DIR, "hooks-package.json"), path.join(b.hooksDir, "package.json"));
|
|
67
|
+
copy(path.join(TEMPLATES_DIR, "gitattributes"), path.join(b.dir, ".gitattributes"));
|
|
68
|
+
const config = loadConfig(b.config);
|
|
69
|
+
registerHooks(path.join(projectRoot, config.claude.settingsPath));
|
|
70
|
+
registerMcp(path.join(projectRoot, ".mcp.json"));
|
|
71
|
+
wireClaudeMd(projectRoot, config.claude.claudeMdPath);
|
|
72
|
+
if (config.map.autoScanOnInit) {
|
|
73
|
+
const count = scanProject(projectRoot, config);
|
|
74
|
+
console.log(chalk.cyan(`• Mapped ${count} files into map.md`));
|
|
75
|
+
}
|
|
76
|
+
registerProject(projectRoot, pkgVersion());
|
|
77
|
+
console.log("\n" + chalk.bold.cyan("PackMind") + ` ${fresh ? "initialized" : "updated"} in ` +
|
|
78
|
+
chalk.bold(path.relative(process.cwd(), projectRoot) || ".") + "\n" +
|
|
79
|
+
` ${chalk.green("✓")} .packmind/ created (map, knowledge, journal, usage, policy, recall)\n` +
|
|
80
|
+
` ${chalk.green("✓")} Claude Code hooks registered (tagged _managedBy: packmind)\n` +
|
|
81
|
+
` ${chalk.green("✓")} packmind MCP server registered in .mcp.json\n\n` +
|
|
82
|
+
`Run ${chalk.bold("packmind index")} to build the semantic index, then use ${chalk.bold("claude")} as normal.\n`);
|
|
83
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { requireProject } from "./ctx.js";
|
|
4
|
+
import { computeInsights } from "../cost/insights.js";
|
|
5
|
+
export function runInsights() {
|
|
6
|
+
const { projectRoot, config } = requireProject();
|
|
7
|
+
const r = computeInsights(projectRoot, config);
|
|
8
|
+
console.log(chalk.bold.cyan("\nPackMind insights — ") + chalk.bold(path.basename(projectRoot)));
|
|
9
|
+
console.log(` cost so far: ${chalk.green("$" + r.totalCost.toFixed(4))} ` +
|
|
10
|
+
chalk.dim(`(${r.inputTokens.toLocaleString()} in / ${r.outputTokens.toLocaleString()} out)`));
|
|
11
|
+
console.log(` est. saved: ${chalk.green("$" + r.estCostSaved.toFixed(4))} ` +
|
|
12
|
+
chalk.dim(`(~${r.estTokensSaved.toLocaleString()} tokens, ${r.reReadsAvoided} re-reads avoided)`));
|
|
13
|
+
console.log(` map coverage: ${r.mapCoverage === null ? "—" : Math.round(r.mapCoverage * 100) + "%"}`);
|
|
14
|
+
if (r.topFiles.length) {
|
|
15
|
+
console.log(chalk.bold("\n Heaviest files:"));
|
|
16
|
+
for (const f of r.topFiles) {
|
|
17
|
+
console.log(` ${chalk.dim(f.tokens.toString().padStart(6))} tok ${chalk.dim("$" + f.cost.toFixed(4))} ${f.file}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (r.flags.length) {
|
|
21
|
+
console.log(chalk.bold("\n Notes:"));
|
|
22
|
+
for (const f of r.flags) {
|
|
23
|
+
const icon = f.level === "good" ? chalk.green("✓") : chalk.yellow("!");
|
|
24
|
+
console.log(` ${icon} ${f.title} — ${chalk.dim(f.detail)}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
console.log("");
|
|
28
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
const here = path.dirname(fileURLToPath(import.meta.url)); // dist/cli at runtime
|
|
5
|
+
export const PACKAGE_ROOT = path.resolve(here, "..", "..");
|
|
6
|
+
export const TEMPLATES_DIR = path.join(PACKAGE_ROOT, "src", "templates");
|
|
7
|
+
export const HOOKS_DIST_DIR = path.join(PACKAGE_ROOT, "dist", "hooks");
|
|
8
|
+
export function pkgVersion() {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8")).version ?? "0.0.0";
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return "0.0.0";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { requireProject } from "./ctx.js";
|
|
3
|
+
import { scanProject } from "../state/mapper.js";
|
|
4
|
+
import { consolidateJournal } from "../state/maintain.js";
|
|
5
|
+
import { buildIndex } from "../recall/indexer.js";
|
|
6
|
+
import { LocalEmbedder } from "../recall/embedder.js";
|
|
7
|
+
import { pruneSnapshots } from "../state/snapshot.js";
|
|
8
|
+
/**
|
|
9
|
+
* One-shot maintenance: refresh the map, rebuild the recall index, archive an
|
|
10
|
+
* overgrown journal, and prune old backups. Designed to be run from the user's
|
|
11
|
+
* own scheduler (cron/launchd) — no persistent daemon, no ports, no state to
|
|
12
|
+
* leak. `--quiet` suppresses output for unattended runs.
|
|
13
|
+
*/
|
|
14
|
+
export async function runMaintain(opts = {}) {
|
|
15
|
+
const { projectRoot, config } = requireProject();
|
|
16
|
+
const say = (m) => {
|
|
17
|
+
if (!opts.quiet)
|
|
18
|
+
console.log(m);
|
|
19
|
+
};
|
|
20
|
+
const files = scanProject(projectRoot, config);
|
|
21
|
+
say(chalk.cyan(`• map refreshed — ${files} files`));
|
|
22
|
+
if (config.recall.enabled) {
|
|
23
|
+
try {
|
|
24
|
+
const n = await buildIndex(projectRoot, config, new LocalEmbedder(config.recall.embedModel));
|
|
25
|
+
say(chalk.cyan(`• recall reindexed — ${n} chunks`));
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
say(chalk.yellow(`• recall skipped — ${err.message.split("\n")[0]}`));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const archived = consolidateJournal(projectRoot);
|
|
32
|
+
if (archived)
|
|
33
|
+
say(chalk.cyan(`• journal archived — ${archived} old lines`));
|
|
34
|
+
const keep = opts.keepBackups ? parseInt(opts.keepBackups, 10) : 10;
|
|
35
|
+
const pruned = pruneSnapshots(projectRoot, keep);
|
|
36
|
+
if (pruned)
|
|
37
|
+
say(chalk.cyan(`• backups pruned — ${pruned} removed (kept ${keep})`));
|
|
38
|
+
say(chalk.green("✓ maintenance complete"));
|
|
39
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { requireProject } from "./ctx.js";
|
|
3
|
+
import { readJsonOr } from "../util/fs-atomic.js";
|
|
4
|
+
import { brain } from "../state/files.js";
|
|
5
|
+
export function runPolicyCheck() {
|
|
6
|
+
const { projectRoot } = requireProject();
|
|
7
|
+
const policy = readJsonOr(brain(projectRoot).policy, {});
|
|
8
|
+
const rules = policy.rules ?? [];
|
|
9
|
+
let problems = 0;
|
|
10
|
+
console.log(chalk.bold.cyan(`\nPolicy: ${rules.length} rule(s)\n`));
|
|
11
|
+
for (const r of rules) {
|
|
12
|
+
const issues = [];
|
|
13
|
+
if (!r.id)
|
|
14
|
+
issues.push("missing id");
|
|
15
|
+
if (!r.message)
|
|
16
|
+
issues.push("missing message");
|
|
17
|
+
if (r.severity !== "warn" && r.severity !== "block")
|
|
18
|
+
issues.push("severity must be warn|block");
|
|
19
|
+
if (!r.secretFile && !r.pathGlob && !r.content)
|
|
20
|
+
issues.push("rule matches nothing (need secretFile/pathGlob/content)");
|
|
21
|
+
if (r.content) {
|
|
22
|
+
try {
|
|
23
|
+
new RegExp(r.content);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
issues.push("invalid content regex");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (issues.length) {
|
|
30
|
+
problems++;
|
|
31
|
+
console.log(` ${chalk.red("✗")} ${r.id || "(no id)"} — ${issues.join("; ")}`);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
console.log(` ${chalk.green("✓")} ${r.id} (${r.severity})`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
console.log("");
|
|
38
|
+
if (problems)
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { requireProject } from "./ctx.js";
|
|
3
|
+
import { recall } from "../recall/indexer.js";
|
|
4
|
+
import { LocalEmbedder } from "../recall/embedder.js";
|
|
5
|
+
export async function runRecall(query) {
|
|
6
|
+
const { projectRoot, config } = requireProject();
|
|
7
|
+
const embedder = new LocalEmbedder(config.recall.embedModel);
|
|
8
|
+
const hits = await recall(projectRoot, config, embedder, query);
|
|
9
|
+
if (hits.length === 0) {
|
|
10
|
+
console.log(chalk.dim("No matches. Run `packmind index` if you haven't yet."));
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
for (const h of hits) {
|
|
14
|
+
console.log(chalk.bold.cyan(`\n[${h.kind}] `) + chalk.dim(h.source) + chalk.green(` ${h.score.toFixed(2)}`));
|
|
15
|
+
console.log(" " + h.text.slice(0, 400).replace(/\n/g, "\n "));
|
|
16
|
+
}
|
|
17
|
+
console.log("");
|
|
18
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { userRoot } from "../util/platform.js";
|
|
4
|
+
import { toPosix } from "../util/paths.js";
|
|
5
|
+
import { readJsonOr, writeJson } from "../util/fs-atomic.js";
|
|
6
|
+
function registryPath() {
|
|
7
|
+
return path.join(userRoot(), "registry.json");
|
|
8
|
+
}
|
|
9
|
+
function norm(root) {
|
|
10
|
+
return toPosix(path.resolve(root));
|
|
11
|
+
}
|
|
12
|
+
export function readRegistry() {
|
|
13
|
+
const list = readJsonOr(registryPath(), []);
|
|
14
|
+
return Array.isArray(list) ? list : [];
|
|
15
|
+
}
|
|
16
|
+
export function registerProject(root, version) {
|
|
17
|
+
const existing = readRegistry();
|
|
18
|
+
const prior = existing.find((e) => norm(e.root) === norm(root));
|
|
19
|
+
const kept = existing.filter((e) => norm(e.root) !== norm(root));
|
|
20
|
+
kept.push({
|
|
21
|
+
root: norm(root),
|
|
22
|
+
name: path.basename(path.resolve(root)),
|
|
23
|
+
registeredAt: prior?.registeredAt ?? new Date().toISOString(),
|
|
24
|
+
version,
|
|
25
|
+
});
|
|
26
|
+
writeJson(registryPath(), kept);
|
|
27
|
+
}
|
|
28
|
+
export function pruneRegistry() {
|
|
29
|
+
const kept = readRegistry().filter((e) => fs.existsSync(path.join(e.root, ".packmind")));
|
|
30
|
+
writeJson(registryPath(), kept);
|
|
31
|
+
return kept;
|
|
32
|
+
}
|
package/dist/cli/scan.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { requireProject } from "./ctx.js";
|
|
3
|
+
import { buildMap, scanProject, countMapEntries, currentMapEntries } from "../state/mapper.js";
|
|
4
|
+
export function runScan(opts = {}) {
|
|
5
|
+
const { projectRoot, config } = requireProject();
|
|
6
|
+
if (opts.check) {
|
|
7
|
+
const fresh = buildMap(projectRoot, config);
|
|
8
|
+
const drift = Math.abs(countMapEntries(fresh.content) - currentMapEntries(projectRoot));
|
|
9
|
+
if (drift > 0) {
|
|
10
|
+
console.error(chalk.yellow(`map.md is stale (${drift} file(s) differ). Run \`packmind scan\`.`));
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
console.log(chalk.green("✓ map.md is up to date."));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const count = scanProject(projectRoot, config);
|
|
17
|
+
console.log(chalk.cyan(`✓ Mapped ${count} files into map.md`));
|
|
18
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { requireProject } from "./ctx.js";
|
|
3
|
+
import { readJsonOr } from "../util/fs-atomic.js";
|
|
4
|
+
import { brain } from "../state/files.js";
|
|
5
|
+
export function runSolutions(term) {
|
|
6
|
+
const { projectRoot } = requireProject();
|
|
7
|
+
const all = readJsonOr(brain(projectRoot).solutions, []);
|
|
8
|
+
const q = term.toLowerCase();
|
|
9
|
+
const hits = all.filter((s) => [s.error, s.cause, s.fix, ...(s.tags ?? [])].filter(Boolean).join(" ").toLowerCase().includes(q));
|
|
10
|
+
if (hits.length === 0) {
|
|
11
|
+
console.log(chalk.dim(`No solutions matching "${term}".`));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
for (const s of hits) {
|
|
15
|
+
console.log(chalk.bold(`\n${s.id}`) + (s.tags?.length ? chalk.dim(` [${s.tags.join(", ")}]`) : ""));
|
|
16
|
+
if (s.error)
|
|
17
|
+
console.log(` error: ${s.error}`);
|
|
18
|
+
if (s.cause)
|
|
19
|
+
console.log(` cause: ${s.cause}`);
|
|
20
|
+
if (s.fix)
|
|
21
|
+
console.log(` fix: ${chalk.green(s.fix)}`);
|
|
22
|
+
}
|
|
23
|
+
console.log("");
|
|
24
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { requireProject } from "./ctx.js";
|
|
4
|
+
import { readLedger, totalCost } from "../cost/ledger.js";
|
|
5
|
+
import { readTextOr } from "../util/fs-atomic.js";
|
|
6
|
+
import { brain } from "../state/files.js";
|
|
7
|
+
import { countMapEntries } from "../state/mapper.js";
|
|
8
|
+
import { VectorStore } from "../recall/store.js";
|
|
9
|
+
import { peekQueue } from "../recall/queue.js";
|
|
10
|
+
export function runStatus() {
|
|
11
|
+
const { projectRoot, config } = requireProject();
|
|
12
|
+
const ledger = readLedger(projectRoot, config.model);
|
|
13
|
+
const t = ledger.totals;
|
|
14
|
+
const files = countMapEntries(readTextOr(brain(projectRoot).map));
|
|
15
|
+
const vectors = new VectorStore(brain(projectRoot).vectors).size();
|
|
16
|
+
const pending = peekQueue(projectRoot).length;
|
|
17
|
+
console.log(chalk.bold.cyan("\nPackMind — ") + chalk.bold(path.basename(projectRoot)));
|
|
18
|
+
console.log(` model: ${ledger.model}`);
|
|
19
|
+
console.log(` map: ${files} files`);
|
|
20
|
+
console.log(` recall: ${vectors} vectors indexed` + (pending ? chalk.dim(` (${pending} queued)`) : ""));
|
|
21
|
+
console.log(` sessions: ${t.sessions}`);
|
|
22
|
+
console.log(` reads: ${t.reads} ` + chalk.dim(`(${t.dedupedReads} re-reads avoided, ${t.mapHits} map hits)`));
|
|
23
|
+
console.log(` writes: ${t.writes}`);
|
|
24
|
+
console.log(` tokens: ${t.inputTokens.toLocaleString()} in / ${t.outputTokens.toLocaleString()} out`);
|
|
25
|
+
console.log(` ${chalk.bold("cost:")} ${chalk.green("$" + totalCost(ledger).toFixed(4))} ` +
|
|
26
|
+
chalk.dim(`($${t.inputCost.toFixed(4)} in / $${t.outputCost.toFixed(4)} out)`));
|
|
27
|
+
console.log("");
|
|
28
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { readJsonOr, writeJson } from "../util/fs-atomic.js";
|
|
5
|
+
import { DEFAULT_CONFIG, deepMerge } from "../state/schema.js";
|
|
6
|
+
import { brain } from "../state/files.js";
|
|
7
|
+
import { registerHooks, registerMcp } from "../adapters/claude-code.js";
|
|
8
|
+
import { TEMPLATES_DIR, HOOKS_DIST_DIR, pkgVersion } from "./locate.js";
|
|
9
|
+
import { pruneRegistry, registerProject } from "./registry.js";
|
|
10
|
+
import { createSnapshot } from "../state/snapshot.js";
|
|
11
|
+
const ALWAYS_OVERWRITE = ["PACKMIND.md"];
|
|
12
|
+
const HOOK_SCRIPTS = [
|
|
13
|
+
"runtime.js", "session-start.js", "prompt-submit.js", "pre-read.js",
|
|
14
|
+
"post-read.js", "pre-write.js", "post-write.js", "stop.js",
|
|
15
|
+
];
|
|
16
|
+
function copy(src, dest) {
|
|
17
|
+
if (fs.existsSync(src))
|
|
18
|
+
fs.writeFileSync(dest, fs.readFileSync(src));
|
|
19
|
+
}
|
|
20
|
+
function updateOne(entry, dryRun) {
|
|
21
|
+
const b = brain(entry.root);
|
|
22
|
+
if (!fs.existsSync(b.dir)) {
|
|
23
|
+
console.log(chalk.dim(` skip ${entry.name} (no .packmind/)`));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (dryRun) {
|
|
27
|
+
console.log(chalk.dim(` would update ${entry.name}`));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
// Safety net: snapshot before mutating anything.
|
|
31
|
+
try {
|
|
32
|
+
createSnapshot(entry.root);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
/* backup is best-effort; never block an update on it */
|
|
36
|
+
}
|
|
37
|
+
for (const f of ALWAYS_OVERWRITE)
|
|
38
|
+
copy(path.join(TEMPLATES_DIR, f), path.join(b.dir, f));
|
|
39
|
+
// config.json: deep-merge template defaults UNDER the user's existing config,
|
|
40
|
+
// preserving any values they customized while adding new keys.
|
|
41
|
+
const existing = readJsonOr(b.config, {});
|
|
42
|
+
writeJson(b.config, deepMerge(DEFAULT_CONFIG, existing));
|
|
43
|
+
fs.mkdirSync(b.hooksDir, { recursive: true });
|
|
44
|
+
for (const s of HOOK_SCRIPTS)
|
|
45
|
+
copy(path.join(HOOKS_DIST_DIR, s), path.join(b.hooksDir, s));
|
|
46
|
+
copy(path.join(TEMPLATES_DIR, "hooks-package.json"), path.join(b.hooksDir, "package.json"));
|
|
47
|
+
const config = deepMerge(DEFAULT_CONFIG, existing);
|
|
48
|
+
registerHooks(path.join(entry.root, config.claude.settingsPath));
|
|
49
|
+
registerMcp(path.join(entry.root, ".mcp.json"));
|
|
50
|
+
registerProject(entry.root, pkgVersion());
|
|
51
|
+
console.log(` ${chalk.green("✓")} ${entry.name}`);
|
|
52
|
+
}
|
|
53
|
+
export function runUpdate(opts = {}) {
|
|
54
|
+
const projects = pruneRegistry();
|
|
55
|
+
if (opts.list) {
|
|
56
|
+
console.log(chalk.bold.cyan("\nRegistered projects:\n"));
|
|
57
|
+
for (const p of projects)
|
|
58
|
+
console.log(` ${p.name} ${chalk.dim(p.root)} v${p.version}`);
|
|
59
|
+
console.log("");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const targets = opts.project
|
|
63
|
+
? projects.filter((p) => p.name === opts.project || p.root.includes(opts.project))
|
|
64
|
+
: projects;
|
|
65
|
+
if (targets.length === 0) {
|
|
66
|
+
console.log(chalk.dim("No matching projects."));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
console.log(chalk.bold.cyan(`\nUpdating ${targets.length} project(s) to v${pkgVersion()}...\n`));
|
|
70
|
+
for (const t of targets)
|
|
71
|
+
updateOne(t, !!opts.dryRun);
|
|
72
|
+
console.log("");
|
|
73
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
const CODE_EXT = new Set([
|
|
3
|
+
".ts", ".tsx", ".js", ".jsx", ".py", ".rs", ".go", ".java", ".kt", ".c",
|
|
4
|
+
".cpp", ".h", ".hpp", ".cs", ".rb", ".php", ".swift", ".scala", ".sh",
|
|
5
|
+
".css", ".scss", ".sql", ".json", ".yaml", ".yml", ".toml", ".xml", ".html",
|
|
6
|
+
]);
|
|
7
|
+
/**
|
|
8
|
+
* Local, offline token estimate. Blends a character-rate model with a word
|
|
9
|
+
* count, which tracks real BPE tokenization more closely than chars alone
|
|
10
|
+
* (whitespace-dense code over-counts on chars; prose under-counts on words).
|
|
11
|
+
*/
|
|
12
|
+
export function estimateTokens(text, hint) {
|
|
13
|
+
if (!text)
|
|
14
|
+
return 0;
|
|
15
|
+
const ext = hint ? path.extname(hint).toLowerCase() : "";
|
|
16
|
+
const charsPerToken = CODE_EXT.has(ext) ? 3.5 : 4.0;
|
|
17
|
+
const byChars = text.length / charsPerToken;
|
|
18
|
+
const words = text.trim().length ? text.trim().split(/\s+/).length : 0;
|
|
19
|
+
const byWords = words / 0.75; // ~1.33 tokens per word
|
|
20
|
+
return Math.max(1, Math.round((byChars + byWords) / 2));
|
|
21
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact token counting via Anthropic's count-tokens endpoint. Used only by the
|
|
3
|
+
* CLI and MCP server (never in the synchronous hook path). Returns null when no
|
|
4
|
+
* API key is configured or the request fails — callers fall back to estimates.
|
|
5
|
+
*/
|
|
6
|
+
export async function countTokensExact(text, model) {
|
|
7
|
+
const key = process.env.ANTHROPIC_API_KEY;
|
|
8
|
+
if (!key || !text)
|
|
9
|
+
return null;
|
|
10
|
+
try {
|
|
11
|
+
const res = await fetch("https://api.anthropic.com/v1/messages/count_tokens", {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: {
|
|
14
|
+
"content-type": "application/json",
|
|
15
|
+
"x-api-key": key,
|
|
16
|
+
"anthropic-version": "2023-06-01",
|
|
17
|
+
},
|
|
18
|
+
body: JSON.stringify({ model, messages: [{ role: "user", content: text }] }),
|
|
19
|
+
});
|
|
20
|
+
if (!res.ok)
|
|
21
|
+
return null;
|
|
22
|
+
const data = (await res.json());
|
|
23
|
+
return typeof data.input_tokens === "number" ? data.input_tokens : null;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function exactEnabled(mode) {
|
|
30
|
+
if (mode === "never")
|
|
31
|
+
return false;
|
|
32
|
+
if (mode === "always")
|
|
33
|
+
return true;
|
|
34
|
+
return Boolean(process.env.ANTHROPIC_API_KEY);
|
|
35
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { brain } from "../state/files.js";
|
|
3
|
+
import { readLedger, totalCost } from "./ledger.js";
|
|
4
|
+
import { inputCost } from "./pricing.js";
|
|
5
|
+
import { parseMap } from "../state/formats.js";
|
|
6
|
+
import { readTextOr } from "../util/fs-atomic.js";
|
|
7
|
+
import { peekQueue } from "../recall/queue.js";
|
|
8
|
+
function sizeOf(p) {
|
|
9
|
+
try {
|
|
10
|
+
return fs.statSync(p).size;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function ageDays(p) {
|
|
17
|
+
try {
|
|
18
|
+
return (Date.now() - fs.statSync(p).mtimeMs) / 86_400_000;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Derive the "where are tokens going / being saved" picture from existing state. */
|
|
25
|
+
export function computeInsights(projectRoot, config) {
|
|
26
|
+
const b = brain(projectRoot);
|
|
27
|
+
const ledger = readLedger(projectRoot, config.model);
|
|
28
|
+
const t = ledger.totals;
|
|
29
|
+
// Map: average file size + top files by cost.
|
|
30
|
+
const map = parseMap(readTextOr(b.map));
|
|
31
|
+
const entries = [];
|
|
32
|
+
let mapTokens = 0;
|
|
33
|
+
for (const [section, list] of map) {
|
|
34
|
+
for (const e of list) {
|
|
35
|
+
const cost = e.cost ?? inputCost(config.model, e.tokens);
|
|
36
|
+
entries.push({ file: section + e.file, tokens: e.tokens, cost });
|
|
37
|
+
mapTokens += e.tokens;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const avgFileTokens = entries.length ? mapTokens / entries.length : 0;
|
|
41
|
+
const topFiles = entries.sort((a, b2) => b2.tokens - a.tokens).slice(0, 5);
|
|
42
|
+
const mapCoverage = t.reads > 0 ? Math.min(1, t.mapHits / t.reads) : null;
|
|
43
|
+
// Conservative savings estimate: map hits avoid ~half a file read on average;
|
|
44
|
+
// each deduped re-read avoids a whole one.
|
|
45
|
+
const estTokensSaved = Math.round(t.mapHits * avgFileTokens * 0.5 + t.dedupedReads * avgFileTokens);
|
|
46
|
+
const estCostSaved = inputCost(config.model, estTokensSaved);
|
|
47
|
+
const flags = [];
|
|
48
|
+
if (t.dedupedReads > 0) {
|
|
49
|
+
flags.push({ level: "good", title: "Re-reads avoided", detail: `${t.dedupedReads} redundant reads skipped this project.` });
|
|
50
|
+
}
|
|
51
|
+
if (mapCoverage !== null && mapCoverage < 0.6 && t.reads >= 10) {
|
|
52
|
+
flags.push({ level: "warn", title: "Low map coverage", detail: `Only ${Math.round(mapCoverage * 100)}% of reads hit a map description — run \`packmind scan\`.` });
|
|
53
|
+
}
|
|
54
|
+
const pending = peekQueue(projectRoot).length;
|
|
55
|
+
if (pending > 0) {
|
|
56
|
+
flags.push({ level: "warn", title: "Recall index stale", detail: `${pending} file(s) changed since the last index — run \`packmind index\` or \`packmind maintain\`.` });
|
|
57
|
+
}
|
|
58
|
+
const journalKB = Math.round(sizeOf(b.journal) / 1024);
|
|
59
|
+
if (journalKB > 60) {
|
|
60
|
+
flags.push({ level: "warn", title: "Journal is large", detail: `journal.md is ${journalKB}KB — run \`packmind maintain\` to archive old entries.` });
|
|
61
|
+
}
|
|
62
|
+
const ka = ageDays(b.knowledge);
|
|
63
|
+
if (ka !== null && ka > 21) {
|
|
64
|
+
flags.push({ level: "warn", title: "Knowledge is stale", detail: `knowledge.md hasn't changed in ${Math.round(ka)} days — capture recent decisions with the \`remember\` tool.` });
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
model: ledger.model,
|
|
68
|
+
totalCost: totalCost(ledger),
|
|
69
|
+
inputTokens: t.inputTokens,
|
|
70
|
+
outputTokens: t.outputTokens,
|
|
71
|
+
reads: t.reads,
|
|
72
|
+
writes: t.writes,
|
|
73
|
+
mapCoverage,
|
|
74
|
+
reReadsAvoided: t.dedupedReads,
|
|
75
|
+
estTokensSaved,
|
|
76
|
+
estCostSaved,
|
|
77
|
+
topFiles,
|
|
78
|
+
flags,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readJsonOr, writeJson } from "../util/fs-atomic.js";
|
|
2
|
+
import { brain } from "../state/files.js";
|
|
3
|
+
export function emptyLedger(model) {
|
|
4
|
+
return {
|
|
5
|
+
version: 1,
|
|
6
|
+
model,
|
|
7
|
+
createdAt: new Date().toISOString(),
|
|
8
|
+
totals: {
|
|
9
|
+
inputTokens: 0, outputTokens: 0, inputCost: 0, outputCost: 0,
|
|
10
|
+
reads: 0, writes: 0, sessions: 0, dedupedReads: 0, mapHits: 0,
|
|
11
|
+
},
|
|
12
|
+
sessions: [],
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function readLedger(projectRoot, model) {
|
|
16
|
+
return readJsonOr(brain(projectRoot).usage, emptyLedger(model));
|
|
17
|
+
}
|
|
18
|
+
export function totalCost(l) {
|
|
19
|
+
return l.totals.inputCost + l.totals.outputCost;
|
|
20
|
+
}
|
|
21
|
+
/** Fold a finished session into the lifetime ledger. */
|
|
22
|
+
export function commitSession(projectRoot, model, s) {
|
|
23
|
+
const ledger = readLedger(projectRoot, model);
|
|
24
|
+
const reads = Object.keys(s.reads).length;
|
|
25
|
+
ledger.sessions.push({
|
|
26
|
+
id: s.id,
|
|
27
|
+
started: s.started,
|
|
28
|
+
ended: new Date().toISOString(),
|
|
29
|
+
inputTokens: s.inputTokens,
|
|
30
|
+
outputTokens: s.outputTokens,
|
|
31
|
+
inputCost: s.inputCost,
|
|
32
|
+
outputCost: s.outputCost,
|
|
33
|
+
reads,
|
|
34
|
+
writes: s.writes.length,
|
|
35
|
+
});
|
|
36
|
+
const t = ledger.totals;
|
|
37
|
+
t.inputTokens += s.inputTokens;
|
|
38
|
+
t.outputTokens += s.outputTokens;
|
|
39
|
+
t.inputCost += s.inputCost;
|
|
40
|
+
t.outputCost += s.outputCost;
|
|
41
|
+
t.reads += reads;
|
|
42
|
+
t.writes += s.writes.length;
|
|
43
|
+
t.sessions += 1;
|
|
44
|
+
t.dedupedReads += s.dedupedReads;
|
|
45
|
+
t.mapHits += s.mapHits;
|
|
46
|
+
writeJson(brain(projectRoot).usage, ledger);
|
|
47
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const PRICES = {
|
|
2
|
+
"claude-opus-4-8": { inputPerMTok: 15, outputPerMTok: 75 },
|
|
3
|
+
"claude-sonnet-4-6": { inputPerMTok: 3, outputPerMTok: 15 },
|
|
4
|
+
"claude-haiku-4-5": { inputPerMTok: 1, outputPerMTok: 5 },
|
|
5
|
+
"claude-fable-5": { inputPerMTok: 5, outputPerMTok: 25 },
|
|
6
|
+
};
|
|
7
|
+
function normalizeModel(model) {
|
|
8
|
+
const m = model.toLowerCase();
|
|
9
|
+
if (m.includes("opus"))
|
|
10
|
+
return "claude-opus-4-8";
|
|
11
|
+
if (m.includes("sonnet"))
|
|
12
|
+
return "claude-sonnet-4-6";
|
|
13
|
+
if (m.includes("haiku"))
|
|
14
|
+
return "claude-haiku-4-5";
|
|
15
|
+
if (m.includes("fable"))
|
|
16
|
+
return "claude-fable-5";
|
|
17
|
+
return "claude-opus-4-8";
|
|
18
|
+
}
|
|
19
|
+
export function rateFor(model) {
|
|
20
|
+
return PRICES[normalizeModel(model)] ?? PRICES["claude-opus-4-8"];
|
|
21
|
+
}
|
|
22
|
+
export function inputCost(model, tokens) {
|
|
23
|
+
return (tokens / 1_000_000) * rateFor(model).inputPerMTok;
|
|
24
|
+
}
|
|
25
|
+
export function outputCost(model, tokens) {
|
|
26
|
+
return (tokens / 1_000_000) * rateFor(model).outputPerMTok;
|
|
27
|
+
}
|