akm-cli 0.9.0-beta.26 → 0.9.0-beta.28
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 +64 -0
- package/dist/cli.js +2 -0
- package/dist/commands/agent/contribute-cli.js +20 -0
- package/dist/commands/fact/fact-cli.js +155 -0
- package/dist/commands/fact/fact-context.js +149 -0
- package/dist/commands/improve/improve.js +281 -146
- package/dist/commands/lint/fact-linter.js +39 -0
- package/dist/commands/lint/index.js +1 -0
- package/dist/commands/lint/registry.js +2 -0
- package/dist/commands/read/curate.js +50 -1
- package/dist/commands/read/knowledge.js +7 -4
- package/dist/commands/read/show.js +67 -2
- package/dist/core/asset/asset-registry.js +2 -0
- package/dist/core/asset/asset-spec.js +14 -0
- package/dist/core/config/config-schema.js +14 -2
- package/dist/indexer/db/db.js +17 -0
- package/dist/indexer/db/graph-db.js +48 -0
- package/dist/indexer/graph/graph-extraction.js +203 -3
- package/dist/indexer/search/ranking-contributors.js +22 -0
- package/dist/indexer/walk/matchers.js +9 -0
- package/dist/integrations/agent/prompts.js +1 -0
- package/dist/output/renderers.js +73 -1
- package/dist/output/shapes/passthrough.js +3 -0
- package/dist/scripts/migrate-storage.js +88 -4
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +30 -0
- package/package.json +6 -5
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,70 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.9.0-beta.28] — 2026-06-20
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **#630 — `fact` asset type phase 2: pinned-core assembly + `akm fact` CLI.**
|
|
14
|
+
Phase 1 (#629) added the `fact` type; phase 2 delivers the "pinned core +
|
|
15
|
+
just-in-time retrieval" model. `collectPinnedFacts()` reads `fact` entries from
|
|
16
|
+
the index (pre-filtered on the `pinned` search hint), excludes
|
|
17
|
+
`stale`/`superseded`/`archived` facts from the core (they stay searchable), and
|
|
18
|
+
**fails soft** — a missing index or unreadable file yields an empty list, so
|
|
19
|
+
collection never blocks a dispatch. `buildPinnedFactsBlock()` assembles a
|
|
20
|
+
deterministic, category-grouped `## Stash facts` block, and `akm agent`
|
|
21
|
+
prepends it to the system prompt for user-facing runs (only when a task or agent
|
|
22
|
+
asset is present; bare interactive launches stay clean; opt out with
|
|
23
|
+
`--no-facts`). New `akm fact` CLI: `add <name> [body] --category <c> [--pinned]`
|
|
24
|
+
(hot-capture à la `akm remember`), `list [--category] [--pinned]`, and `context`
|
|
25
|
+
(print the assembled pinned core). Design note: `docs/design/fact-asset-type.md`.
|
|
26
|
+
|
|
27
|
+
## [0.9.0-beta.27] — 2026-06-20
|
|
28
|
+
|
|
29
|
+
All new behavior is **opt-in / default-preserving** — default runs are byte-identical.
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- **#624 P2 — priority-ranked graph extraction.** `processes.graphExtraction.topN`:
|
|
34
|
+
when set, the graph-extraction pass ranks eligible files by asset utility
|
|
35
|
+
(`utility_scores`, read-only join) and processes only the top-N per run, so
|
|
36
|
+
high-value assets get graphed first instead of a ~55h full-corpus sweep. Unset
|
|
37
|
+
(default) = no ranking, byte-identical.
|
|
38
|
+
- **#624 P3 — lazy on-demand graph extraction.** New `graph_extraction_queue` table
|
|
39
|
+
+ `enqueueGraphExtraction`/`drainExtractionQueue`/`extractGraphForSingleFile`.
|
|
40
|
+
`akm curate` enqueues an ungraphed hit (non-blocking); `akm show` can extract a
|
|
41
|
+
missing graph inline — gated on `index.graph.lazyGraphExtraction: true`
|
|
42
|
+
(**default off**: `show` makes no LLM call by default), model-guarded, and bounded
|
|
43
|
+
by a 30s timeout so it never hangs. The pass drains the queue before the ranked
|
|
44
|
+
sweep. This **closes #624** (all three layers shipped).
|
|
45
|
+
- **#616 — bounded multi-cycle phasing.** `profiles.improve.<name>.maxCycles`
|
|
46
|
+
(default 1): when > 1, the improve passes run in an N-cycle loop so gate-accepted
|
|
47
|
+
output of cycle N feeds cycle N+1 within the same run (re-running ensureIndex +
|
|
48
|
+
ref selection each cycle), stopping at a fixed point and respecting the run budget.
|
|
49
|
+
`maxCycles: 1` = byte-identical to today.
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- **Release CI unblocked.** `runCliCapture` (test harness) restored `process.exitCode`
|
|
54
|
+
to a captured `undefined`, which under `bun test` does not clear a previously-set
|
|
55
|
+
non-zero exit code — so the unit suite exited 1 with 0 failures at `TEST_PARALLEL=1`
|
|
56
|
+
(exactly how `release.yml` runs), silently blocking every npm publish since beta.11.
|
|
57
|
+
Fixed to restore to `0`. (This is why beta.26 was the first successful workflow publish.)
|
|
58
|
+
|
|
59
|
+
### Changed
|
|
60
|
+
|
|
61
|
+
- **CI/release tests sharded across runner jobs (~15 min → ~2 min).** Bun 1.3.x
|
|
62
|
+
in-process test parallelism (`--parallel=N`, N>1) hits an intermittent
|
|
63
|
+
`epoll_ctl EEXIST` race / busy-spin hang on the `--isolate` workers, which had
|
|
64
|
+
forced fully-sequential (`TEST_PARALLEL=1`) runs. Tests now shard across separate
|
|
65
|
+
runner jobs (each a separate process tree, so no cross-shard fd/epoll collisions)
|
|
66
|
+
with `--parallel=1` within each shard; the matrix runs shards concurrently. The
|
|
67
|
+
release gate runs the identical set of tests. Local `bun run check` defaults to
|
|
68
|
+
sequential too (the only safe mode on this Bun version). Coverage unchanged.
|
|
69
|
+
Each shard runs through `scripts/run-test-shard.sh`, which retries **only on a
|
|
70
|
+
hang/timeout** (the busy-spin can rarely fire even at `--parallel=1`) and never
|
|
71
|
+
on a real test failure, so genuine red tests still fail fast and are never masked.
|
|
72
|
+
|
|
9
73
|
## [0.9.0-beta.26] — 2026-06-20
|
|
10
74
|
|
|
11
75
|
### Added
|
package/dist/cli.js
CHANGED
|
@@ -63,6 +63,7 @@ import { generateBashCompletions, installBashCompletions } from "./commands/comp
|
|
|
63
63
|
import { configCommand } from "./commands/config-cli.js";
|
|
64
64
|
import { envCommand } from "./commands/env/env-cli.js";
|
|
65
65
|
import { secretCommand } from "./commands/env/secret-cli.js";
|
|
66
|
+
import { factCommand } from "./commands/fact/fact-cli.js";
|
|
66
67
|
import { feedbackCommand } from "./commands/feedback-cli.js";
|
|
67
68
|
import { graphCommand } from "./commands/graph/graph-cli.js";
|
|
68
69
|
import { akmHealth, parseWindowSpec, renderRunsDetailMd, renderWindowCompareMd, } from "./commands/health.js";
|
|
@@ -519,6 +520,7 @@ export const main = defineCommand({
|
|
|
519
520
|
secret: secretCommand,
|
|
520
521
|
wiki: wikiCommand,
|
|
521
522
|
tasks: tasksCommand,
|
|
523
|
+
fact: factCommand,
|
|
522
524
|
},
|
|
523
525
|
});
|
|
524
526
|
// ── Exit codes ──────────────────────────────────────────────────────────────
|
|
@@ -29,6 +29,7 @@ import { assertFlatAssetName, combineCreatePath, normalizeCreateSubPath } from "
|
|
|
29
29
|
import { loadConfig } from "../../core/config/config.js";
|
|
30
30
|
import { UsageError } from "../../core/errors.js";
|
|
31
31
|
import { getHyphenatedArg } from "../../output/context.js";
|
|
32
|
+
import { buildPinnedFactsContext } from "../fact/fact-context.js";
|
|
32
33
|
import { akmLint } from "../lint.js";
|
|
33
34
|
import { akmPropose } from "../proposal/propose.js";
|
|
34
35
|
import { akmAgentDispatch } from "./agent-dispatch.js";
|
|
@@ -57,6 +58,11 @@ export const agentCommand = defineCommand({
|
|
|
57
58
|
description: "Model override — accepts aliases (opus, sonnet, haiku) or exact platform model IDs. Overrides the model specified in the agent asset.",
|
|
58
59
|
},
|
|
59
60
|
"timeout-ms": { type: "string", description: "Override the agent CLI timeout in milliseconds" },
|
|
61
|
+
"no-facts": {
|
|
62
|
+
type: "boolean",
|
|
63
|
+
description: "Do not prepend the pinned stash facts (fact: assets with `pinned: true`) to the system prompt",
|
|
64
|
+
default: false,
|
|
65
|
+
},
|
|
60
66
|
},
|
|
61
67
|
async run({ args }) {
|
|
62
68
|
await runWithJsonErrors(async () => {
|
|
@@ -85,6 +91,20 @@ export const agentCommand = defineCommand({
|
|
|
85
91
|
const promptText = getStringArg(args, "prompt");
|
|
86
92
|
const commandRef = getStringArg(args, "command");
|
|
87
93
|
const workflowRef = getStringArg(args, "workflow");
|
|
94
|
+
// Phase 2: prepend the pinned-fact "core" (fact assets with
|
|
95
|
+
// `pinned: true`) to the system prompt for user-facing agent runs, so
|
|
96
|
+
// durable stash facts and conventions are always in context. Only injects
|
|
97
|
+
// when there is actually a task or agent asset — bare interactive launches
|
|
98
|
+
// (`akm agent claude`) stay clean. Opt out with --no-facts. Fails soft:
|
|
99
|
+
// buildPinnedFactsContext never throws, so fact collection can't block a
|
|
100
|
+
// dispatch.
|
|
101
|
+
if (getHyphenatedArg(args, "no-facts") !== true &&
|
|
102
|
+
(promptText ?? commandRef ?? workflowRef ?? agentRef)) {
|
|
103
|
+
const factsBlock = buildPinnedFactsContext();
|
|
104
|
+
if (factsBlock) {
|
|
105
|
+
systemPrompt = systemPrompt ? `${factsBlock}\n\n${systemPrompt}` : factsBlock;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
88
108
|
// Only build a dispatch request when there is something to dispatch — a
|
|
89
109
|
// prompt, an agent asset, or a model override. When none of these are
|
|
90
110
|
// present the agent is launched interactively (no injected prompt, no
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* `akm fact` — manage durable stash-level facts (the `fact` asset type, phase 2).
|
|
6
|
+
*
|
|
7
|
+
* Subcommands:
|
|
8
|
+
* - `add <name> [content]` — hot-capture a fact (à la `akm remember`), writing
|
|
9
|
+
* `facts/<category>/<name>.md` with `category` + optional `pinned`.
|
|
10
|
+
* - `list` — list indexed facts, optionally filtered by `--category` / `--pinned`.
|
|
11
|
+
* - `context` — print the assembled "pinned core" block (the same block the
|
|
12
|
+
* `akm agent` dispatch prepends to a system prompt). Useful for previewing
|
|
13
|
+
* or piping into an AGENTS.md / CLAUDE.md.
|
|
14
|
+
*/
|
|
15
|
+
import { defineCommand } from "citty";
|
|
16
|
+
import { defineJsonCommand, output } from "../../cli/shared.js";
|
|
17
|
+
import { serializeFrontmatter } from "../../core/asset/asset-serialize.js";
|
|
18
|
+
import { toErrorMessage, tryReadStdinText } from "../../core/common.js";
|
|
19
|
+
import { UsageError } from "../../core/errors.js";
|
|
20
|
+
import { appendEvent } from "../../core/events.js";
|
|
21
|
+
import { closeDatabase, getAllEntries, openExistingDatabase } from "../../indexer/db/db.js";
|
|
22
|
+
import { assertFlatAssetName, writeMarkdownAsset } from "../read/knowledge.js";
|
|
23
|
+
import { buildPinnedFactsBlock, collectPinnedFacts } from "./fact-context.js";
|
|
24
|
+
/** Recommended categories (mirrors the fact linter). Free values are allowed but linted. */
|
|
25
|
+
const KNOWN_CATEGORIES = ["personal", "team", "project", "convention", "meta"];
|
|
26
|
+
function buildFactFrontmatter(fields) {
|
|
27
|
+
const obj = { category: fields.category };
|
|
28
|
+
if (fields.description?.trim())
|
|
29
|
+
obj.description = fields.description.trim();
|
|
30
|
+
if (fields.pinned)
|
|
31
|
+
obj.pinned = true;
|
|
32
|
+
obj.updated = new Date().toISOString().slice(0, 10);
|
|
33
|
+
return `---\n${serializeFrontmatter(obj)}\n---`;
|
|
34
|
+
}
|
|
35
|
+
const addCommand = defineJsonCommand({
|
|
36
|
+
meta: { name: "add", description: "Record a durable stash fact (facts/<category>/<name>.md)" },
|
|
37
|
+
args: {
|
|
38
|
+
name: {
|
|
39
|
+
type: "positional",
|
|
40
|
+
description: "Fact name (flat, no '/'; use --category for the subdirectory)",
|
|
41
|
+
required: false,
|
|
42
|
+
},
|
|
43
|
+
content: { type: "positional", description: "Fact body. Omit to read markdown from stdin.", required: false },
|
|
44
|
+
category: {
|
|
45
|
+
type: "string",
|
|
46
|
+
description: `Fact category — scopes the fact (one of: ${KNOWN_CATEGORIES.join(", ")}; free values allowed but linted)`,
|
|
47
|
+
},
|
|
48
|
+
pinned: { type: "boolean", description: "Mark this fact as always-injected core context", default: false },
|
|
49
|
+
description: { type: "string", description: "Short description written to frontmatter" },
|
|
50
|
+
path: { type: "string", description: "Subdirectory under facts/ (defaults to --category)" },
|
|
51
|
+
force: { type: "boolean", description: "Overwrite an existing fact with the same name", default: false },
|
|
52
|
+
target: { type: "string", description: "Override the write destination (a configured source name)" },
|
|
53
|
+
},
|
|
54
|
+
async run({ args }) {
|
|
55
|
+
if (!args.name || typeof args.name !== "string") {
|
|
56
|
+
throw new UsageError("Usage: akm fact add <name> [content] --category <category>", "MISSING_REQUIRED_ARGUMENT", "Provide a flat fact name; pass the body as a quoted argument or via stdin.");
|
|
57
|
+
}
|
|
58
|
+
assertFlatAssetName(args.name);
|
|
59
|
+
const category = typeof args.category === "string" ? args.category.trim() : "";
|
|
60
|
+
if (!category) {
|
|
61
|
+
throw new UsageError(`A --category is required (one of: ${KNOWN_CATEGORIES.join(", ")}).`, "MISSING_REQUIRED_ARGUMENT");
|
|
62
|
+
}
|
|
63
|
+
const body = (typeof args.content === "string" ? args.content : undefined) ?? tryReadStdinText();
|
|
64
|
+
if (!body?.trim()) {
|
|
65
|
+
throw new UsageError("Fact content is required. Pass quoted text or pipe markdown into stdin.");
|
|
66
|
+
}
|
|
67
|
+
const description = typeof args.description === "string" ? args.description : undefined;
|
|
68
|
+
const pinned = args.pinned === true;
|
|
69
|
+
const frontmatter = buildFactFrontmatter({ description, category, pinned });
|
|
70
|
+
const subPath = typeof args.path === "string" && args.path.trim() ? args.path.trim() : category;
|
|
71
|
+
const result = await writeMarkdownAsset({
|
|
72
|
+
type: "fact",
|
|
73
|
+
content: `${frontmatter}\n${body}`,
|
|
74
|
+
name: args.name,
|
|
75
|
+
fallbackPrefix: "fact",
|
|
76
|
+
force: args.force === true,
|
|
77
|
+
target: typeof args.target === "string" ? args.target : undefined,
|
|
78
|
+
path: subPath,
|
|
79
|
+
});
|
|
80
|
+
appendEvent({
|
|
81
|
+
eventType: "fact_add",
|
|
82
|
+
ref: result.ref,
|
|
83
|
+
metadata: { path: result.path, category, pinned, force: args.force === true },
|
|
84
|
+
});
|
|
85
|
+
output("fact-add", { ok: true, ...result, category, pinned });
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
/** Pull the `category:<x>` value out of an entry's search hints, if present. */
|
|
89
|
+
function categoryFromHints(hints) {
|
|
90
|
+
const hit = hints?.find((h) => h.startsWith("category:"));
|
|
91
|
+
return hit ? hit.slice("category:".length) : undefined;
|
|
92
|
+
}
|
|
93
|
+
const listCommand = defineJsonCommand({
|
|
94
|
+
meta: { name: "list", description: "List indexed facts (optionally filtered by --category / --pinned)" },
|
|
95
|
+
args: {
|
|
96
|
+
category: { type: "string", description: "Only list facts in this category" },
|
|
97
|
+
pinned: { type: "boolean", description: "Only list pinned (always-injected core) facts", default: false },
|
|
98
|
+
},
|
|
99
|
+
run({ args }) {
|
|
100
|
+
const categoryFilter = typeof args.category === "string" ? args.category.trim() : undefined;
|
|
101
|
+
const pinnedOnly = args.pinned === true;
|
|
102
|
+
let db;
|
|
103
|
+
let facts = [];
|
|
104
|
+
try {
|
|
105
|
+
db = openExistingDatabase();
|
|
106
|
+
facts = getAllEntries(db, "fact")
|
|
107
|
+
.map((row) => {
|
|
108
|
+
const category = categoryFromHints(row.entry.searchHints);
|
|
109
|
+
return {
|
|
110
|
+
ref: `fact:${row.entry.name}`,
|
|
111
|
+
name: row.entry.name,
|
|
112
|
+
...(category ? { category } : {}),
|
|
113
|
+
pinned: row.entry.searchHints?.includes("pinned") ?? false,
|
|
114
|
+
...(row.entry.description ? { description: row.entry.description } : {}),
|
|
115
|
+
};
|
|
116
|
+
})
|
|
117
|
+
.filter((f) => (categoryFilter ? f.category === categoryFilter : true))
|
|
118
|
+
.filter((f) => (pinnedOnly ? f.pinned : true))
|
|
119
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
// No index yet → empty list rather than a hard error.
|
|
123
|
+
void toErrorMessage(err);
|
|
124
|
+
facts = [];
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
if (db)
|
|
128
|
+
closeDatabase(db);
|
|
129
|
+
}
|
|
130
|
+
output("fact-list", { ok: true, facts, totalCount: facts.length });
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
const contextCommand = defineJsonCommand({
|
|
134
|
+
meta: {
|
|
135
|
+
name: "context",
|
|
136
|
+
description: "Print the assembled pinned-fact core block (what `akm agent` injects into the system prompt)",
|
|
137
|
+
},
|
|
138
|
+
run() {
|
|
139
|
+
const facts = collectPinnedFacts();
|
|
140
|
+
output("fact-context", { ok: true, content: buildPinnedFactsBlock(facts), count: facts.length });
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
const factSubCommands = {
|
|
144
|
+
add: addCommand,
|
|
145
|
+
list: listCommand,
|
|
146
|
+
context: contextCommand,
|
|
147
|
+
};
|
|
148
|
+
export const FACT_SUBCOMMAND_SET = new Set(Object.keys(factSubCommands));
|
|
149
|
+
export const factCommand = defineCommand({
|
|
150
|
+
meta: {
|
|
151
|
+
name: "fact",
|
|
152
|
+
description: "Manage durable stash-level facts (the `fact` asset type): personal/team/project details, coding conventions, and stash-meta. Subcommands: add, list, context.",
|
|
153
|
+
},
|
|
154
|
+
subCommands: factSubCommands,
|
|
155
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* Pinned-fact context assembly (fact asset type, phase 2).
|
|
6
|
+
*
|
|
7
|
+
* The `fact` type (see docs/design/fact-asset-type.md) stores durable
|
|
8
|
+
* stash-level semantic knowledge. Facts marked `pinned: true` form the small
|
|
9
|
+
* always-injected "core" — the handful of high-signal facts an agent should
|
|
10
|
+
* always have in context (personal identity, team conventions, the
|
|
11
|
+
* "constitution"). Everything else stays on "disk" and is reached via normal
|
|
12
|
+
* `akm search` / `akm curate` just-in-time retrieval.
|
|
13
|
+
*
|
|
14
|
+
* This module collects the pinned core from the index and assembles it into a
|
|
15
|
+
* compact markdown block that the `akm agent` dispatch prepends to the agent's
|
|
16
|
+
* system prompt. The pure assembly + parse helpers are exported so they can be
|
|
17
|
+
* unit-tested without a database.
|
|
18
|
+
*/
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
21
|
+
import { asNonEmptyString } from "../../core/common.js";
|
|
22
|
+
import { closeDatabase, getAllEntries, openExistingDatabase } from "../../indexer/db/db.js";
|
|
23
|
+
/**
|
|
24
|
+
* Fact `status` values that EXCLUDE a fact from the always-injected core.
|
|
25
|
+
* Durable facts go stale; rather than delete them, an author can mark a fact
|
|
26
|
+
* `status: stale` (or `superseded`) and it drops out of the pinned context
|
|
27
|
+
* while remaining searchable. (Design note phase 2 — staleness handling.)
|
|
28
|
+
*/
|
|
29
|
+
const EXCLUDED_STATUSES = new Set(["stale", "superseded", "archived"]);
|
|
30
|
+
/**
|
|
31
|
+
* Parse a single fact file into a {@link PinnedFact}, or return `null` when the
|
|
32
|
+
* fact is NOT part of the pinned core: not `pinned: true`, or carrying an
|
|
33
|
+
* excluded `status`. Pure — takes the raw file content so it is unit-testable
|
|
34
|
+
* without touching disk.
|
|
35
|
+
*/
|
|
36
|
+
export function parsePinnedFact(name, raw) {
|
|
37
|
+
const parsed = parseFrontmatter(raw);
|
|
38
|
+
const fm = parsed.data;
|
|
39
|
+
if (fm.pinned !== true)
|
|
40
|
+
return null;
|
|
41
|
+
const status = asNonEmptyString(fm.status)?.toLowerCase();
|
|
42
|
+
if (status && EXCLUDED_STATUSES.has(status))
|
|
43
|
+
return null;
|
|
44
|
+
const body = parsed.content.trim();
|
|
45
|
+
return {
|
|
46
|
+
ref: `fact:${name}`,
|
|
47
|
+
name,
|
|
48
|
+
...(asNonEmptyString(fm.category) ? { category: asNonEmptyString(fm.category) } : {}),
|
|
49
|
+
...(asNonEmptyString(fm.description) ? { description: asNonEmptyString(fm.description) } : {}),
|
|
50
|
+
body,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Render a list of pinned facts into a compact markdown context block, grouped
|
|
55
|
+
* by category. Returns `""` for an empty list so callers can treat "no facts"
|
|
56
|
+
* uniformly. Pure.
|
|
57
|
+
*/
|
|
58
|
+
export function buildPinnedFactsBlock(facts) {
|
|
59
|
+
if (facts.length === 0)
|
|
60
|
+
return "";
|
|
61
|
+
// Stable order: by category then name, so the block is deterministic.
|
|
62
|
+
const sorted = [...facts].sort((a, b) => {
|
|
63
|
+
const ca = a.category ?? "";
|
|
64
|
+
const cb = b.category ?? "";
|
|
65
|
+
if (ca !== cb)
|
|
66
|
+
return ca.localeCompare(cb);
|
|
67
|
+
return a.name.localeCompare(b.name);
|
|
68
|
+
});
|
|
69
|
+
const lines = [
|
|
70
|
+
"## Stash facts",
|
|
71
|
+
"",
|
|
72
|
+
"Durable, authoritative facts about this user / team / project. Treat them as background context and follow any conventions they state.",
|
|
73
|
+
"",
|
|
74
|
+
];
|
|
75
|
+
let lastCategory;
|
|
76
|
+
for (const fact of sorted) {
|
|
77
|
+
const category = fact.category ?? "general";
|
|
78
|
+
if (category !== lastCategory) {
|
|
79
|
+
lines.push(`### ${category}`, "");
|
|
80
|
+
lastCategory = category;
|
|
81
|
+
}
|
|
82
|
+
const heading = fact.description ? `${fact.name} — ${fact.description}` : fact.name;
|
|
83
|
+
lines.push(`- **${heading}**`);
|
|
84
|
+
if (fact.body) {
|
|
85
|
+
// Indent the body two spaces so it reads as part of the bullet.
|
|
86
|
+
for (const bodyLine of fact.body.split("\n")) {
|
|
87
|
+
lines.push(bodyLine.length > 0 ? ` ${bodyLine}` : "");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
lines.push("");
|
|
91
|
+
}
|
|
92
|
+
return lines.join("\n").trimEnd();
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Collect the pinned core from the index. Opens the existing index DB (unless
|
|
96
|
+
* one is injected for tests), reads every `fact` entry whose `pinned` search
|
|
97
|
+
* hint is set, then parses each file from disk. Fails soft: any error (missing
|
|
98
|
+
* index, unreadable file) yields an empty list rather than throwing, so callers
|
|
99
|
+
* on the hot path (agent dispatch) are never broken by fact collection.
|
|
100
|
+
*/
|
|
101
|
+
export function collectPinnedFacts(injectedDb) {
|
|
102
|
+
let db;
|
|
103
|
+
let ownsDb = false;
|
|
104
|
+
try {
|
|
105
|
+
db = injectedDb ?? openExistingDatabase();
|
|
106
|
+
ownsDb = injectedDb === undefined;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const entries = getAllEntries(db, "fact");
|
|
113
|
+
const facts = [];
|
|
114
|
+
for (const row of entries) {
|
|
115
|
+
// Fast pre-filter on the indexed `pinned` search hint so we only read
|
|
116
|
+
// files for facts that are actually part of the core.
|
|
117
|
+
if (!row.entry.searchHints?.includes("pinned"))
|
|
118
|
+
continue;
|
|
119
|
+
let raw;
|
|
120
|
+
try {
|
|
121
|
+
raw = fs.readFileSync(row.filePath, "utf8");
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const fact = parsePinnedFact(row.entry.name, raw);
|
|
127
|
+
if (fact)
|
|
128
|
+
facts.push(fact);
|
|
129
|
+
}
|
|
130
|
+
return facts;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
if (ownsDb && db)
|
|
137
|
+
closeDatabase(db);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Convenience for the agent-dispatch seam: collect the pinned core and return
|
|
142
|
+
* the assembled markdown block, or `undefined` when there are no pinned facts.
|
|
143
|
+
*/
|
|
144
|
+
export function buildPinnedFactsContext(injectedDb) {
|
|
145
|
+
const facts = collectPinnedFacts(injectedDb);
|
|
146
|
+
if (facts.length === 0)
|
|
147
|
+
return undefined;
|
|
148
|
+
return buildPinnedFactsBlock(facts);
|
|
149
|
+
}
|