forge-workflow 0.1.0-beta.2 → 0.1.0-beta.4
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/.forge/hooks/check-tdd.js +79 -5
- package/.forge/hooks/forge-native-hook.js +194 -8
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +28 -0
- package/QUICKSTART.md +6 -2
- package/README.md +3 -1
- package/bin/forge-cmd.js +1 -1
- package/bin/forge.js +95 -19
- package/docs/guides/SETUP.md +4 -1
- package/docs/guides/SUPPORT.md +5 -0
- package/docs/reference/COMMANDS.md +12 -1
- package/docs/reference/shepherd.md +114 -3
- package/lib/activation/ensure-forge-home.js +135 -0
- package/lib/adapters/beads-kernel-compat.js +67 -0
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +103 -8
- package/lib/adoption-profiles.js +17 -4
- package/lib/agents-config.js +5 -0
- package/lib/beads-detect.js +60 -0
- package/lib/commands/_aliases.js +248 -0
- package/lib/commands/_issue.js +65 -41
- package/lib/commands/_manifest.js +3 -1
- package/lib/commands/_registry.js +14 -0
- package/lib/commands/_resolve-command-opts.js +36 -60
- package/lib/commands/claim.js +2 -4
- package/lib/commands/gate.js +19 -2
- package/lib/commands/hooks.js +294 -12
- package/lib/commands/init.js +26 -20
- package/lib/commands/memory.js +81 -0
- package/lib/commands/migrate.js +0 -161
- package/lib/commands/plan.js +71 -123
- package/lib/commands/pr.js +88 -0
- package/lib/commands/prime.js +8 -1
- package/lib/commands/push.js +66 -0
- package/lib/commands/recall.js +67 -12
- package/lib/commands/recap.js +18 -4
- package/lib/commands/release.js +15 -3
- package/lib/commands/remember.js +86 -20
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +135 -73
- package/lib/commands/shepherd.js +102 -4
- package/lib/commands/ship.js +40 -4
- package/lib/commands/skill.js +275 -0
- package/lib/commands/status.js +37 -32
- package/lib/commands/test.js +32 -0
- package/lib/commands/worktree.js +87 -5
- package/lib/core/runtime-graph.js +122 -17
- package/lib/forge-issues.js +13 -464
- package/lib/gate-events.js +54 -55
- package/lib/global-flags.js +30 -0
- package/lib/grounding/context-events.js +230 -0
- package/lib/grounding/read-first.js +112 -0
- package/lib/harness-capability-matrix.js +2 -2
- package/lib/hook-renderer.js +144 -12
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/backing-issue.js +7 -1
- package/lib/kernel/broker.js +28 -0
- package/lib/kernel/migrations.js +30 -2
- package/lib/kernel/owned-kernel.js +43 -0
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +187 -1
- package/lib/memory-recall.js +151 -0
- package/lib/orientation.js +303 -6
- package/lib/pr-bundle.js +6 -2
- package/lib/pr-monitor/auto-actions.js +175 -0
- package/lib/pr-monitor/digest.js +206 -0
- package/lib/pr-monitor/reconcile-executor.js +659 -0
- package/lib/pr-monitor/reconcile-tick.js +138 -0
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-sticky.js +57 -8
- package/lib/pr-monitor/shepherd-lease.js +243 -0
- package/lib/pr-monitor/upsert-sticky.js +169 -0
- package/lib/pr-monitor/watch-lifecycle.js +1 -1
- package/lib/pr-pull.js +113 -17
- package/lib/project-memory.js +8 -0
- package/lib/release-readiness.js +17 -1
- package/lib/rules-sync.js +4 -0
- package/lib/runtime-health.js +15 -46
- package/lib/skill-eval.js +750 -0
- package/lib/status/identity.js +46 -0
- package/lib/status/presenter.js +0 -35
- package/lib/status/snapshot.js +11 -16
- package/lib/upgrade-safety.js +52 -1
- package/lib/using-forge.js +315 -0
- package/lib/workflow/enforce-stage.js +64 -7
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +2 -2
- package/rules/using-forge.md +24 -0
- package/scripts/forge-team/index.sh +0 -5
- package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
- package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
- package/scripts/pr-auto-actions.js +93 -0
- package/scripts/pr-verdict-label.js +50 -0
- package/scripts/test.js +8 -1
- package/skills/claim-safety/SKILL.md +4 -0
- package/skills/claim-safety/evals/scorecard.json +41 -0
- package/skills/coverage.json +83 -0
- package/skills/dev/SKILL.md +4 -0
- package/skills/dev/evals/scorecard.json +41 -0
- package/skills/gates/SKILL.md +80 -0
- package/skills/gates/evals/evals.json +38 -0
- package/skills/gates/evals/scorecard.json +41 -0
- package/skills/hermes-forge/SKILL.md +1 -0
- package/skills/hermes-forge/evals/scorecard.json +41 -0
- package/skills/issue-basics/SKILL.md +1 -0
- package/skills/issue-basics/evals/scorecard.json +41 -0
- package/skills/kernel/SKILL.md +38 -0
- package/skills/kernel/evals/scorecard.json +41 -0
- package/skills/memory/SKILL.md +16 -1
- package/skills/memory/evals/scorecard.json +41 -0
- package/skills/parallel-deep-research/SKILL.md +1 -0
- package/skills/parallel-deep-research/evals/scorecard.json +41 -0
- package/skills/plan/SKILL.md +6 -0
- package/skills/plan/evals/scorecard.json +41 -0
- package/skills/portability/SKILL.md +47 -0
- package/skills/portability/evals/evals.json +34 -0
- package/skills/portability/evals/scorecard.json +41 -0
- package/skills/research/SKILL.md +1 -0
- package/skills/research/evals/scorecard.json +41 -0
- package/skills/review/SKILL.md +6 -0
- package/skills/review/evals/scorecard.json +41 -0
- package/skills/rollback/SKILL.md +1 -0
- package/skills/rollback/evals/scorecard.json +41 -0
- package/skills/setup/SKILL.md +91 -0
- package/skills/setup/evals/evals.json +42 -0
- package/skills/setup/evals/scorecard.json +41 -0
- package/skills/shepherd/SKILL.md +76 -36
- package/skills/shepherd/evals/evals.json +21 -9
- package/skills/shepherd/evals/scorecard.json +41 -0
- package/skills/ship/SKILL.md +6 -0
- package/skills/ship/evals/scorecard.json +41 -0
- package/skills/smith/SKILL.md +8 -0
- package/skills/smith/evals/scorecard.json +41 -0
- package/skills/sonarcloud/SKILL.md +1 -0
- package/skills/sonarcloud/evals/scorecard.json +41 -0
- package/skills/sonarcloud-analysis/SKILL.md +1 -0
- package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
- package/skills/status/SKILL.md +3 -0
- package/skills/status/evals/scorecard.json +41 -0
- package/skills/triage-ready/SKILL.md +2 -0
- package/skills/triage-ready/evals/scorecard.json +41 -0
- package/skills/using-forge/SKILL.md +104 -0
- package/skills/using-forge/evals/scorecard.json +41 -0
- package/skills/validate/SKILL.md +4 -0
- package/skills/validate/evals/scorecard.json +41 -0
- package/skills/verify/SKILL.md +4 -0
- package/skills/verify/evals/scorecard.json +41 -0
- package/skills/worktree/SKILL.md +87 -0
- package/skills/worktree/evals/evals.json +38 -0
- package/skills/worktree/evals/scorecard.json +41 -0
- package/lib/adapters/beads-issue-adapter.js +0 -127
- package/lib/commands/board.js +0 -64
- package/lib/status/beads-snapshot.js +0 -145
- package/scripts/forge-team/lib/dashboard.sh +0 -316
- package/scripts/forge-team/tests/dashboard.test.sh +0 -155
package/lib/issue-backend.js
CHANGED
|
@@ -3,10 +3,34 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
|
|
6
|
-
const VALID_BACKENDS = new Set(['kernel'
|
|
6
|
+
const VALID_BACKENDS = new Set(['kernel']);
|
|
7
7
|
const DEFAULT_BACKEND = 'kernel';
|
|
8
8
|
const ENV_VAR = 'FORGE_ISSUE_BACKEND';
|
|
9
9
|
|
|
10
|
+
// Backends that Forge used to accept and has since retired. Kept as an explicit set
|
|
11
|
+
// (rather than folding them into the generic "unknown backend" path) so a user who
|
|
12
|
+
// still carries `issueBackend: beads` in config — or `FORGE_ISSUE_BACKEND=beads` in a
|
|
13
|
+
// shell profile — gets the ONE actionable instruction instead of a bare valid-values
|
|
14
|
+
// list: import the Beads store into the kernel.
|
|
15
|
+
const REMOVED_BACKENDS = new Set(['beads']);
|
|
16
|
+
|
|
17
|
+
// The single migrate pointer shared by every removed-backend surface (the resolver's
|
|
18
|
+
// warning and the CLI flag's hard error) so the two can never drift.
|
|
19
|
+
const BEADS_REMOVED_HINT =
|
|
20
|
+
'the beads backend was removed; run `forge migrate --from beads` to import a Beads store into the kernel';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The migrate-pointer hint for a retired backend value, or null when the value is
|
|
24
|
+
* not a retired backend (callers then use the generic unknown-backend wording).
|
|
25
|
+
*
|
|
26
|
+
* @param {string} value
|
|
27
|
+
* @returns {string|null}
|
|
28
|
+
*/
|
|
29
|
+
function removedBackendHint(value) {
|
|
30
|
+
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
31
|
+
return REMOVED_BACKENDS.has(normalized) ? BEADS_REMOVED_HINT : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
10
34
|
/**
|
|
11
35
|
* Read the `issueBackend` key from `<projectRoot>/.forge/config.yaml`, if the
|
|
12
36
|
* file exists and is parseable. Returns `null` when the file is missing, the
|
|
@@ -72,14 +96,17 @@ function collectBackendSignal({ deps = {}, env = process.env, projectRoot } = {}
|
|
|
72
96
|
* explicit deps.issueBackend > FORGE_ISSUE_BACKEND env > .forge/config.yaml > 'kernel'.
|
|
73
97
|
*
|
|
74
98
|
* An unknown value (from any source) falls back to the default backend and emits
|
|
75
|
-
* a warning via the injected `warn` callback (defaults to console.warn).
|
|
99
|
+
* a warning via the injected `warn` callback (defaults to console.warn). A RETIRED
|
|
100
|
+
* value (`beads`) takes the same fallback path but warns with the migrate pointer,
|
|
101
|
+
* because "unknown backend, valid backends: kernel" would not tell a user carrying
|
|
102
|
+
* `issueBackend: beads` in config what to actually do about it.
|
|
76
103
|
*
|
|
77
104
|
* @param {object} [options]
|
|
78
105
|
* @param {object} [options.deps]
|
|
79
106
|
* @param {object} [options.env]
|
|
80
107
|
* @param {string} [options.projectRoot]
|
|
81
108
|
* @param {function(string): void} [options.warn]
|
|
82
|
-
* @returns {'kernel'
|
|
109
|
+
* @returns {'kernel'}
|
|
83
110
|
*/
|
|
84
111
|
function resolveIssueBackend({
|
|
85
112
|
deps = {},
|
|
@@ -98,6 +125,15 @@ function resolveIssueBackend({
|
|
|
98
125
|
return normalized;
|
|
99
126
|
}
|
|
100
127
|
|
|
128
|
+
const removedHint = removedBackendHint(normalized);
|
|
129
|
+
if (removedHint) {
|
|
130
|
+
warn(
|
|
131
|
+
`Issue backend "${value}" from ${source} is no longer available: ${removedHint}. `
|
|
132
|
+
+ `Falling back to "${DEFAULT_BACKEND}".`,
|
|
133
|
+
);
|
|
134
|
+
return DEFAULT_BACKEND;
|
|
135
|
+
}
|
|
136
|
+
|
|
101
137
|
warn(
|
|
102
138
|
`Unknown issue backend "${value}" from ${source}; `
|
|
103
139
|
+ `falling back to "${DEFAULT_BACKEND}". Valid backends: ${[...VALID_BACKENDS].join(', ')}.`,
|
|
@@ -139,7 +175,10 @@ module.exports = {
|
|
|
139
175
|
hasExplicitBackendSignal,
|
|
140
176
|
shouldUseKernelBroker,
|
|
141
177
|
readConfigBackend,
|
|
178
|
+
removedBackendHint,
|
|
142
179
|
VALID_BACKENDS,
|
|
180
|
+
REMOVED_BACKENDS,
|
|
181
|
+
BEADS_REMOVED_HINT,
|
|
143
182
|
DEFAULT_BACKEND,
|
|
144
183
|
ENV_VAR,
|
|
145
184
|
};
|
|
@@ -171,7 +171,12 @@ function findExistingLink(driver, { worktreePath, branch }) {
|
|
|
171
171
|
}
|
|
172
172
|
if (typeof driver.listWorktrees === 'function') {
|
|
173
173
|
const rows = driver.listWorktrees() || [];
|
|
174
|
-
|
|
174
|
+
// Match ACTIVE (live) rows only: a superseded/stale registration for a
|
|
175
|
+
// reused branch name must not be treated as the existing link (be18881c —
|
|
176
|
+
// the third resolver, kept consistent with resolveActiveIssueId and
|
|
177
|
+
// currentBranchIssueFromDriver). Tolerate a null state for legacy rows.
|
|
178
|
+
const match = rows.find(row => row && row.branch === branch && row.issue_id
|
|
179
|
+
&& (row.state === 'active' || row.state == null));
|
|
175
180
|
if (match) return match;
|
|
176
181
|
}
|
|
177
182
|
} catch {
|
|
@@ -298,6 +303,7 @@ module.exports = {
|
|
|
298
303
|
classifyBranch,
|
|
299
304
|
deriveTitle,
|
|
300
305
|
extractEncodedIssueId,
|
|
306
|
+
findExistingLink,
|
|
301
307
|
matchesIgnoreGlob,
|
|
302
308
|
DEFAULT_PROTECTED_BRANCHES,
|
|
303
309
|
DEFAULT_IGNORE_GLOBS,
|
package/lib/kernel/broker.js
CHANGED
|
@@ -1177,6 +1177,34 @@ function createLocalBroker(options = {}) {
|
|
|
1177
1177
|
return driver.importIssues(kernel, options, {}, config);
|
|
1178
1178
|
},
|
|
1179
1179
|
|
|
1180
|
+
// PR reconcile-ledger read (autonomous-shepherd design §3.4). Read-only SELECT of
|
|
1181
|
+
// the open `pr` rows for a repo (keyed by git_common_dir); creates/migrates nothing.
|
|
1182
|
+
// Consumed later by prime and the reconciler to enumerate PRs under shepherd.
|
|
1183
|
+
async listOpenPrs(gitCommonDir, context = {}) {
|
|
1184
|
+
requireDriverMethod(driver, 'listOpenPrs');
|
|
1185
|
+
return driver.listOpenPrs(gitCommonDir, context, getConfig());
|
|
1186
|
+
},
|
|
1187
|
+
|
|
1188
|
+
// PR reconcile-ledger WRITE path (autonomous-shepherd design §5a). Mirror of the
|
|
1189
|
+
// listOpenPrs wrapper: guard the driver method, delegate with getConfig(). pr rows
|
|
1190
|
+
// are derived reconcile state (a direct idempotent upsert), not the guarded-event
|
|
1191
|
+
// issue path. upsertPr = register/refresh; updatePrVerdict = the one verdict authority
|
|
1192
|
+
// with freshest-head precedence enforced at the write; retirePr = merged/closed.
|
|
1193
|
+
async upsertPr(row, context = {}) {
|
|
1194
|
+
requireDriverMethod(driver, 'upsertPr');
|
|
1195
|
+
return driver.upsertPr(row, context, getConfig());
|
|
1196
|
+
},
|
|
1197
|
+
|
|
1198
|
+
async updatePrVerdict(key, patch = {}, context = {}) {
|
|
1199
|
+
requireDriverMethod(driver, 'updatePrVerdict');
|
|
1200
|
+
return driver.updatePrVerdict(key, patch, context, getConfig());
|
|
1201
|
+
},
|
|
1202
|
+
|
|
1203
|
+
async retirePr(key, patch = {}, context = {}) {
|
|
1204
|
+
requireDriverMethod(driver, 'retirePr');
|
|
1205
|
+
return driver.retirePr(key, patch, context, getConfig());
|
|
1206
|
+
},
|
|
1207
|
+
|
|
1180
1208
|
// --- Projection-outbox read/update surface (D16) -----------------------
|
|
1181
1209
|
// Additive read/update methods for projection consumers. These never touch
|
|
1182
1210
|
// the append/CAS path above (runGuardedEvent / enqueueKernelProjection);
|
package/lib/kernel/migrations.js
CHANGED
|
@@ -57,8 +57,8 @@ function renderDropTable(table) {
|
|
|
57
57
|
// stays the full current schema; the named tables are filtered out of 001 so they are
|
|
58
58
|
// created exactly once by their dedicated migration (both on a fresh DB and, via the
|
|
59
59
|
// ledger, on an existing DB). KEEP IN SYNC with every new table-creating migration.
|
|
60
|
-
// memories → 005
|
|
61
|
-
const MIGRATION_ADDED_TABLES = ['memories'];
|
|
60
|
+
// memories → 005 ; pr → 009
|
|
61
|
+
const MIGRATION_ADDED_TABLES = ['memories', 'pr'];
|
|
62
62
|
|
|
63
63
|
function getInitialKernelSchema() {
|
|
64
64
|
const schema = getKernelSchema();
|
|
@@ -278,6 +278,32 @@ function buildMemoryFtsMigration() {
|
|
|
278
278
|
};
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
// 009: the PR reconcile ledger + verdict store (kernel_pr). Rendered from the schema.js
|
|
282
|
+
// table definition so the DDL never drifts from the registry — mirroring migration 005.
|
|
283
|
+
// A NEW authority table (NOT columns on kernel_worktrees) because a PR can outlive its
|
|
284
|
+
// worktree or have none at all (autonomous-shepherd design §3.1). Excluded from the 001
|
|
285
|
+
// initial schema (MIGRATION_ADDED_TABLES), so a fresh DB creates it exactly once here and
|
|
286
|
+
// an existing DB picks it up through the broker's per-migration ledger. CREATE … IF NOT
|
|
287
|
+
// EXISTS keeps a re-run idempotent; it is a create-table with no data backfill, so no
|
|
288
|
+
// BEGIN IMMEDIATE is needed (broker.js apply-loop caveat does not bite).
|
|
289
|
+
function buildPrLinkageMigration() {
|
|
290
|
+
const pr = getKernelSchema().tables.find(table => table.name === 'pr');
|
|
291
|
+
if (!pr) {
|
|
292
|
+
throw new Error('Kernel schema is missing the pr authority table');
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
id: '009_kernel_pr_linkage',
|
|
296
|
+
apply: [
|
|
297
|
+
renderCreateTable(pr),
|
|
298
|
+
...pr.indexes.map(prIndex => renderCreateIndex(pr, prIndex)),
|
|
299
|
+
],
|
|
300
|
+
rollback: [
|
|
301
|
+
...[...pr.indexes].reverse().map(prIndex => renderDropIndex(prIndex)),
|
|
302
|
+
renderDropTable(pr),
|
|
303
|
+
],
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
281
307
|
function validateKernelMigrations(migrations) {
|
|
282
308
|
const ids = new Set();
|
|
283
309
|
for (const migration of migrations) {
|
|
@@ -308,6 +334,7 @@ function buildKernelMigrationPlan(migrations = [
|
|
|
308
334
|
buildIssueFidelityColumnsMigration(),
|
|
309
335
|
buildWorktreeLinkageColumnsMigration(),
|
|
310
336
|
buildMemoryFtsMigration(),
|
|
337
|
+
buildPrLinkageMigration(),
|
|
311
338
|
]) {
|
|
312
339
|
validateKernelMigrations(migrations);
|
|
313
340
|
|
|
@@ -326,6 +353,7 @@ module.exports = {
|
|
|
326
353
|
buildKernelMigrationPlan,
|
|
327
354
|
buildMemoryFtsMigration,
|
|
328
355
|
buildMemoryProjectionMigration,
|
|
356
|
+
buildPrLinkageMigration,
|
|
329
357
|
buildSchemaMigration,
|
|
330
358
|
buildWorktreeLinkageColumnsMigration,
|
|
331
359
|
memoryFtsDdl,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module kernel/owned-kernel
|
|
5
|
+
*
|
|
6
|
+
* Shared kernel-lifecycle helper for the pure-append event modules
|
|
7
|
+
* (grounding/context-events, gate-events). Both resolve a kernel driver the same
|
|
8
|
+
* way and carry the same close-what-you-built invariant, so it lives here once
|
|
9
|
+
* instead of being copied per module.
|
|
10
|
+
*
|
|
11
|
+
* The invariant: an INJECTED (shared) kernel is caller-owned and must NEVER be
|
|
12
|
+
* closed here — closing it would break the next operation that reuses it. A
|
|
13
|
+
* kernel this module BUILDS for a single short-lived read/append it MUST close —
|
|
14
|
+
* an unclosed SQLite handle leaks and, on Windows, locks the DB directory
|
|
15
|
+
* (`EBUSY` on `rmSync`, kernel issue e62e4bde).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { buildMigratedKernelIssueDeps } = require('./cli-broker-factory');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the kernel driver + config. An injected (shared) kernel is returned
|
|
22
|
+
* untouched with `ownsKernel:false` — the caller owns its lifecycle. Otherwise a
|
|
23
|
+
* fresh one is built (via `deps.kernelBuilder`, a test seam over
|
|
24
|
+
* `buildMigratedKernelIssueDeps`, or the real builder) and tagged
|
|
25
|
+
* `ownsKernel:true` so {@link closeIfOwned} closes it.
|
|
26
|
+
*/
|
|
27
|
+
async function resolveOwnedKernel(projectRoot, deps = {}) {
|
|
28
|
+
if (deps.kernelBroker && deps.kernelDriver) {
|
|
29
|
+
return { broker: deps.kernelBroker, driver: deps.kernelDriver, config: deps.kernelBroker.config, ownsKernel: false };
|
|
30
|
+
}
|
|
31
|
+
const build = deps.kernelBuilder || buildMigratedKernelIssueDeps;
|
|
32
|
+
const built = await build({ projectRoot });
|
|
33
|
+
return { broker: built.kernelBroker, driver: built.kernelDriver, config: built.kernelBroker.config, ownsKernel: true };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Close a kernel driver only when this module built it (never an injected one). */
|
|
37
|
+
function closeIfOwned(kernel) {
|
|
38
|
+
if (kernel && kernel.ownsKernel && kernel.driver && typeof kernel.driver.close === 'function') {
|
|
39
|
+
try { kernel.driver.close(); } catch { /* best-effort: closing is cleanup, never fatal */ }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { resolveOwnedKernel, closeIfOwned };
|
package/lib/kernel/schema.js
CHANGED
|
@@ -278,6 +278,41 @@ const TABLE_LIST = deepFreeze([
|
|
|
278
278
|
], [
|
|
279
279
|
index('idx_kernel_memories_source_agent', ['source_agent']),
|
|
280
280
|
]),
|
|
281
|
+
// The PR reconcile ledger + verdict store (autonomous-shepherd design §3): a
|
|
282
|
+
// first-class `pr` authority row links a pull request to its issue, worktree and
|
|
283
|
+
// journal, and records the winning verdict with its freshness discriminators
|
|
284
|
+
// (head_sha, verdict_source, verdict_at). A PR is the unit of ownership and can
|
|
285
|
+
// outlive its worktree (or have none — a hand-opened/other-harness PR), so it is a
|
|
286
|
+
// separate table, NOT columns on kernel_worktrees. git_common_dir keys every open PR
|
|
287
|
+
// to its repo so all worktrees share one reconcile view. issue_id/worktree_id are
|
|
288
|
+
// soft nullable links. Created by migration 009 (excluded from the 001 initial schema
|
|
289
|
+
// via MIGRATION_ADDED_TABLES), so a fresh DB creates it exactly once and an existing
|
|
290
|
+
// DB picks it up through the broker's per-migration ledger.
|
|
291
|
+
table('pr', 'authority', [
|
|
292
|
+
field('id', 'TEXT', { primaryKey: true }),
|
|
293
|
+
field('git_common_dir', 'TEXT', { notNull: true }),
|
|
294
|
+
field('repo', 'TEXT', { notNull: true }),
|
|
295
|
+
field('number', 'INTEGER', { notNull: true }),
|
|
296
|
+
field('issue_id', 'TEXT'),
|
|
297
|
+
field('worktree_id', 'TEXT'),
|
|
298
|
+
field('branch', 'TEXT'),
|
|
299
|
+
field('head_sha', 'TEXT'),
|
|
300
|
+
field('verdict', 'TEXT'),
|
|
301
|
+
field('verdict_source', 'TEXT'),
|
|
302
|
+
field('verdict_at', 'TEXT'),
|
|
303
|
+
field('journal_ptr', 'TEXT'),
|
|
304
|
+
field('state', 'TEXT', { notNull: true, default: "'open'" }),
|
|
305
|
+
field('registered_at', 'TEXT', { notNull: true }),
|
|
306
|
+
field('retired_at', 'TEXT'),
|
|
307
|
+
], [
|
|
308
|
+
// Covering index for the reconciler's hot read `listOpenPrs` (WHERE
|
|
309
|
+
// git_common_dir=? AND state='open' ORDER BY repo, number): the trailing
|
|
310
|
+
// repo/number let SQLite satisfy both the state filter AND the ordering from
|
|
311
|
+
// this one index, instead of scanning every PR for the common-dir once the
|
|
312
|
+
// ledger retains merged/closed history. (Codex review, PR #424.)
|
|
313
|
+
index('idx_pr_common_dir_state_repo_number', ['git_common_dir', 'state', 'repo', 'number']),
|
|
314
|
+
index('idx_pr_common_dir_repo_number', ['git_common_dir', 'repo', 'number'], { unique: true }),
|
|
315
|
+
]),
|
|
281
316
|
]);
|
|
282
317
|
|
|
283
318
|
const KERNEL_TABLES = deepFreeze(Object.fromEntries(TABLE_LIST.map(candidate => [candidate.name, candidate])));
|
|
@@ -1113,8 +1113,41 @@ function loadWorktreeRowByPath(runtime, db, worktreePath) {
|
|
|
1113
1113
|
return rows[0] || null;
|
|
1114
1114
|
}
|
|
1115
1115
|
|
|
1116
|
+
// The idempotent upsert key. `forge plan` registers MULTIPLE branches from ONE
|
|
1117
|
+
// checkout (same absolute path), so keying by path ALONE made a second plan-first
|
|
1118
|
+
// feature UPDATE-in-place over the first branch's row and dead-end its ship (R1).
|
|
1119
|
+
// Key by (path, branch) so each branch keeps its own row; fall back to path-only
|
|
1120
|
+
// when no branch is supplied (worktree flows use distinct paths, so behavior there
|
|
1121
|
+
// is unchanged).
|
|
1122
|
+
function loadWorktreeRowByPathAndBranch(runtime, db, worktreePath, branch) {
|
|
1123
|
+
if (!worktreePath) return null;
|
|
1124
|
+
if (!branch) return loadWorktreeRowByPath(runtime, db, worktreePath);
|
|
1125
|
+
const rows = safeAll(
|
|
1126
|
+
runtime,
|
|
1127
|
+
db,
|
|
1128
|
+
'SELECT * FROM kernel_worktrees WHERE path = ? AND branch = ? ORDER BY registered_at DESC LIMIT 1',
|
|
1129
|
+
[worktreePath, branch],
|
|
1130
|
+
);
|
|
1131
|
+
return rows[0] || null;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// A git branch is checked out in exactly ONE worktree, so a NEW active registration
|
|
1135
|
+
// for a branch supersedes any prior ACTIVE row carrying that same branch under a
|
|
1136
|
+
// different id (be18881c): a reused/deleted-and-recreated branch must not keep a
|
|
1137
|
+
// stale binding to the OLD issue. Marking those rows state='superseded' lets the
|
|
1138
|
+
// active-only branch resolver skip them regardless of their timestamp.
|
|
1139
|
+
function supersedePriorBranchRegistrations(runtime, db, branch, keepId) {
|
|
1140
|
+
if (!branch) return;
|
|
1141
|
+
runParams(
|
|
1142
|
+
runtime,
|
|
1143
|
+
db,
|
|
1144
|
+
"UPDATE kernel_worktrees SET state = 'superseded' WHERE branch = ? AND state = 'active' AND id != ?",
|
|
1145
|
+
[branch, keepId || ''],
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1116
1149
|
function upsertWorktreeRow(runtime, db, input) {
|
|
1117
|
-
const existing =
|
|
1150
|
+
const existing = loadWorktreeRowByPathAndBranch(runtime, db, input.path, input.branch);
|
|
1118
1151
|
const row = {
|
|
1119
1152
|
id: input.id || existing?.id || randomUUID(),
|
|
1120
1153
|
git_common_dir: input.git_common_dir,
|
|
@@ -1142,6 +1175,9 @@ function upsertWorktreeRow(runtime, db, input) {
|
|
|
1142
1175
|
KERNEL_WORKTREE_COLUMNS.map(column => row[column]),
|
|
1143
1176
|
);
|
|
1144
1177
|
}
|
|
1178
|
+
if (row.state === 'active') {
|
|
1179
|
+
supersedePriorBranchRegistrations(runtime, db, row.branch, row.id);
|
|
1180
|
+
}
|
|
1145
1181
|
return row;
|
|
1146
1182
|
}
|
|
1147
1183
|
|
|
@@ -2034,6 +2070,30 @@ function searchMemoryRowsRanked(runtime, db, query, limit) {
|
|
|
2034
2070
|
).map(memoryRowToEntry);
|
|
2035
2071
|
}
|
|
2036
2072
|
|
|
2073
|
+
// Relevance-ONLY BM25 recall that exposes the raw bm25 score on each entry. The
|
|
2074
|
+
// per-turn auto-recall hook needs the score to apply a relevance FLOOR (inject nothing
|
|
2075
|
+
// when nothing clears the bar) — ordinal rank can't express "nothing was relevant".
|
|
2076
|
+
// Unlike searchMemoryRowsRanked, a no-match (or empty) query returns [] with NO recency
|
|
2077
|
+
// fallback: the whole point is to avoid surfacing recent-but-irrelevant notes. bm25()
|
|
2078
|
+
// returns more-negative for stronger matches, so rows come back best (lowest) first.
|
|
2079
|
+
function searchMemoryRowsRankedScored(runtime, db, query, limit) {
|
|
2080
|
+
const capped = Number.isInteger(limit) && limit > 0 ? limit : 20;
|
|
2081
|
+
const match = buildMemoryFtsMatch(query);
|
|
2082
|
+
if (!match) {
|
|
2083
|
+
return [];
|
|
2084
|
+
}
|
|
2085
|
+
return allParams(
|
|
2086
|
+
runtime,
|
|
2087
|
+
db,
|
|
2088
|
+
`SELECT m.*, bm25(kernel_memories_fts) AS __score FROM kernel_memories m
|
|
2089
|
+
JOIN kernel_memories_fts ON kernel_memories_fts.rowid = m.rowid
|
|
2090
|
+
WHERE kernel_memories_fts MATCH ?
|
|
2091
|
+
ORDER BY bm25(kernel_memories_fts)
|
|
2092
|
+
LIMIT ?`,
|
|
2093
|
+
[match, capped],
|
|
2094
|
+
).map(row => ({ ...memoryRowToEntry(row), score: row.__score }));
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2037
2097
|
function closeDatabase(db) {
|
|
2038
2098
|
if (db && typeof db.close === 'function') {
|
|
2039
2099
|
db.close();
|
|
@@ -2145,6 +2205,124 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2145
2205
|
[`${escaped}%`, limit],
|
|
2146
2206
|
);
|
|
2147
2207
|
},
|
|
2208
|
+
// Open PRs under shepherd for one repo (autonomous-shepherd design §3.4): the
|
|
2209
|
+
// reconciler's "open PRs in this repo" read, keyed by git_common_dir so every
|
|
2210
|
+
// worktree shares one view. Parameterized (git_common_dir is a filesystem path —
|
|
2211
|
+
// never interpolate it), covered by idx_pr_common_dir_state_repo_number. Ordered so
|
|
2212
|
+
// the result is deterministic. `context` is part of the broker contract but unused
|
|
2213
|
+
// by this direct SELECT (prefixed `_` for eslint no-unused-vars).
|
|
2214
|
+
async listOpenPrs(gitCommonDir, _context = {}, config = {}) {
|
|
2215
|
+
return allParams(
|
|
2216
|
+
runtime, getDatabase(config),
|
|
2217
|
+
"SELECT * FROM kernel_pr WHERE git_common_dir = ? AND state = 'open' ORDER BY repo ASC, number ASC",
|
|
2218
|
+
[gitCommonDir],
|
|
2219
|
+
);
|
|
2220
|
+
},
|
|
2221
|
+
// --- kernel_pr WRITE path (autonomous-shepherd design §5a). pr rows are DERIVED
|
|
2222
|
+
// reconcile state (reconstructable from GitHub), not audit-critical issue authority,
|
|
2223
|
+
// so they take a DIRECT idempotent upsert — NOT the event-sourced guarded path
|
|
2224
|
+
// (applyAcceptedIssueMutation). All target the physical `kernel_pr` table (matching
|
|
2225
|
+
// the listOpenPrs read) and are parameterized (git_common_dir/branch/head_sha are
|
|
2226
|
+
// externally-influenced values — never interpolate). `context` is part of the broker
|
|
2227
|
+
// contract but unused by these direct writes (prefixed `_` for eslint no-unused-vars).
|
|
2228
|
+
//
|
|
2229
|
+
// Register/refresh a PR row keyed by (git_common_dir, repo, number). Idempotent via
|
|
2230
|
+
// ON CONFLICT on the unique idx_pr_common_dir_repo_number: a re-upsert updates the
|
|
2231
|
+
// mutable columns and coalesces soft links (a later null never clobbers an existing
|
|
2232
|
+
// issue_id/worktree_id). registered_at is set on INSERT only; state defaults 'open'.
|
|
2233
|
+
async upsertPr(row, _context = {}, config = {}) {
|
|
2234
|
+
const id = row.id || randomUUID();
|
|
2235
|
+
const registeredAt = row.registered_at || new Date().toISOString();
|
|
2236
|
+
const state = row.state || 'open';
|
|
2237
|
+
runParams(
|
|
2238
|
+
runtime, getDatabase(config),
|
|
2239
|
+
`INSERT INTO kernel_pr
|
|
2240
|
+
(id, git_common_dir, repo, number, issue_id, worktree_id, branch, head_sha, journal_ptr, state, registered_at)
|
|
2241
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2242
|
+
ON CONFLICT(git_common_dir, repo, number) DO UPDATE SET
|
|
2243
|
+
head_sha = excluded.head_sha,
|
|
2244
|
+
branch = excluded.branch,
|
|
2245
|
+
issue_id = coalesce(excluded.issue_id, kernel_pr.issue_id),
|
|
2246
|
+
worktree_id = coalesce(excluded.worktree_id, kernel_pr.worktree_id),
|
|
2247
|
+
-- journal_ptr is a soft link: coalesce it (like issue_id/worktree_id) so a
|
|
2248
|
+
-- head-only refresh that omits journalPtr never severs the ledger↔journal
|
|
2249
|
+
-- link with NULL. (Codex review, PR #426.)
|
|
2250
|
+
journal_ptr = coalesce(excluded.journal_ptr, kernel_pr.journal_ptr),
|
|
2251
|
+
-- REOPEN semantics: upsertPr is only ever called for PRs GitHub reports as
|
|
2252
|
+
-- OPEN, so re-registering a previously retired row (a reopened PR) must flip
|
|
2253
|
+
-- it back to open and clear retired_at — else listOpenPrs (state='open') would
|
|
2254
|
+
-- keep the reopened PR invisible forever. (Codex review, PR #426.)
|
|
2255
|
+
state = 'open',
|
|
2256
|
+
retired_at = NULL,
|
|
2257
|
+
-- A new commit INVALIDATES the prior verdict: when the head advances to a
|
|
2258
|
+
-- different non-null sha, clear verdict/source/at so a verdict computed
|
|
2259
|
+
-- against the OLD head is never presented as fresh for the new head (and the
|
|
2260
|
+
-- freshest-head guard in updatePrVerdict keeps intact evidence). IS NOT is the
|
|
2261
|
+
-- null-safe distinctness test; a headless refresh (excluded.head_sha NULL)
|
|
2262
|
+
-- never clears. (Codex review, PR #426.)
|
|
2263
|
+
verdict = CASE WHEN excluded.head_sha IS NOT NULL AND excluded.head_sha IS NOT kernel_pr.head_sha THEN NULL ELSE kernel_pr.verdict END,
|
|
2264
|
+
verdict_source = CASE WHEN excluded.head_sha IS NOT NULL AND excluded.head_sha IS NOT kernel_pr.head_sha THEN NULL ELSE kernel_pr.verdict_source END,
|
|
2265
|
+
verdict_at = CASE WHEN excluded.head_sha IS NOT NULL AND excluded.head_sha IS NOT kernel_pr.head_sha THEN NULL ELSE kernel_pr.verdict_at END`,
|
|
2266
|
+
[
|
|
2267
|
+
id,
|
|
2268
|
+
row.git_common_dir,
|
|
2269
|
+
row.repo,
|
|
2270
|
+
row.number,
|
|
2271
|
+
row.issue_id ?? null,
|
|
2272
|
+
row.worktree_id ?? null,
|
|
2273
|
+
row.branch ?? null,
|
|
2274
|
+
row.head_sha ?? null,
|
|
2275
|
+
row.journal_ptr ?? null,
|
|
2276
|
+
state,
|
|
2277
|
+
registeredAt,
|
|
2278
|
+
],
|
|
2279
|
+
);
|
|
2280
|
+
return { ok: true, id };
|
|
2281
|
+
},
|
|
2282
|
+
// The ONE verdict authority WRITE (design §1.2 rule 2) — FRESHEST-HEAD-SHA
|
|
2283
|
+
// PRECEDENCE enforced in the WHERE so a verdict computed against a SUPERSEDED head is
|
|
2284
|
+
// DISCARDED, not written (kills stale 9d35c14b at the write). A non-local (Actions
|
|
2285
|
+
// backstop) write lands only when its head_sha matches the row's current head (or the
|
|
2286
|
+
// row has none yet); a `local` verdict is computed live against the current head and
|
|
2287
|
+
// is always authoritative, so it bypasses the head match.
|
|
2288
|
+
async updatePrVerdict(key, patch = {}, _context = {}, config = {}) {
|
|
2289
|
+
const headSha = patch.head_sha ?? null;
|
|
2290
|
+
const source = patch.verdict_source ?? null;
|
|
2291
|
+
runParams(
|
|
2292
|
+
runtime, getDatabase(config),
|
|
2293
|
+
`UPDATE kernel_pr SET verdict = ?, verdict_source = ?, verdict_at = ?, head_sha = ?
|
|
2294
|
+
WHERE git_common_dir = ? AND repo = ? AND number = ?
|
|
2295
|
+
AND (head_sha IS NULL OR head_sha = ? OR ? = 'local')`,
|
|
2296
|
+
[
|
|
2297
|
+
patch.verdict ?? null,
|
|
2298
|
+
source,
|
|
2299
|
+
patch.verdict_at ?? null,
|
|
2300
|
+
headSha,
|
|
2301
|
+
key.git_common_dir,
|
|
2302
|
+
key.repo,
|
|
2303
|
+
key.number,
|
|
2304
|
+
headSha,
|
|
2305
|
+
source,
|
|
2306
|
+
],
|
|
2307
|
+
);
|
|
2308
|
+
return { ok: true };
|
|
2309
|
+
},
|
|
2310
|
+
// Retire a PR row (merged/closed): flip state + stamp retired_at so it drops out of
|
|
2311
|
+
// the open-PR read while the reconcile history is retained.
|
|
2312
|
+
async retirePr(key, patch = {}, _context = {}, config = {}) {
|
|
2313
|
+
runParams(
|
|
2314
|
+
runtime, getDatabase(config),
|
|
2315
|
+
'UPDATE kernel_pr SET state = ?, retired_at = ? WHERE git_common_dir = ? AND repo = ? AND number = ?',
|
|
2316
|
+
[
|
|
2317
|
+
patch.state ?? 'closed',
|
|
2318
|
+
patch.retired_at ?? new Date().toISOString(),
|
|
2319
|
+
key.git_common_dir,
|
|
2320
|
+
key.repo,
|
|
2321
|
+
key.number,
|
|
2322
|
+
],
|
|
2323
|
+
);
|
|
2324
|
+
return { ok: true };
|
|
2325
|
+
},
|
|
2148
2326
|
// --- Event-store primitives (Wave 2) — composed by broker.runGuardedEvent.
|
|
2149
2327
|
// `context` is part of the broker contract but unused by these direct SQL
|
|
2150
2328
|
// reads/writes (prefixed `_` for eslint no-unused-vars).
|
|
@@ -2277,6 +2455,14 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2277
2455
|
ensureMemorySchema(database);
|
|
2278
2456
|
return searchMemoryRowsRanked(runtime, database, query, limit);
|
|
2279
2457
|
},
|
|
2458
|
+
// Relevance-only BM25 recall that also returns the raw bm25 `score` per entry, so a
|
|
2459
|
+
// caller can apply a relevance floor. A no-match/empty query returns [] (no recency
|
|
2460
|
+
// fallback). Used by the per-turn memory-recall hook.
|
|
2461
|
+
searchMemoriesRankedScored(query, limit, config = {}) {
|
|
2462
|
+
const database = getDatabase(config);
|
|
2463
|
+
ensureMemorySchema(database);
|
|
2464
|
+
return searchMemoryRowsRankedScored(runtime, database, query, limit);
|
|
2465
|
+
},
|
|
2280
2466
|
// The newest `limit` entries (default recall with no query). `options.agents` scopes
|
|
2281
2467
|
// the read to a source_agent allow-list (e.g. human `remember` notes only).
|
|
2282
2468
|
recentMemories(limit, options = {}, config = {}) {
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module memory-recall
|
|
5
|
+
*
|
|
6
|
+
* Pure selection core for the per-turn memory-recall hook (the query-relevant tier-2
|
|
7
|
+
* that complements the recency digest pushed at SessionStart). Kept free of stdin/fs so
|
|
8
|
+
* it is fully testable; lib/commands/hooks.js does the I/O wiring around it.
|
|
9
|
+
*
|
|
10
|
+
* Design constraints (verified against the Claude Code hooks contract + external memory
|
|
11
|
+
* research, kernel issue 781f6f65):
|
|
12
|
+
* - UserPromptSubmit additionalContext APPENDS to history every prompt, so a per-turn
|
|
13
|
+
* injector must stay tiny: a hard token budget, a relevance floor, and cross-turn
|
|
14
|
+
* dedupe. Below the bar -> inject NOTHING (silence is safe; a wrong memory at
|
|
15
|
+
* authority every turn is not).
|
|
16
|
+
* - Anaphora guard: a trivial query ("continue", "fix it") carries no retrieval signal,
|
|
17
|
+
* so ranking on it is worse than silence. Require a minimum of distinct content tokens.
|
|
18
|
+
* - Scope is a FILTER; relevance is the RANKER (bm25). Never sort by recency here — that
|
|
19
|
+
* is the recency digest's job, not tier-2's.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// A query needs at least this many distinct content tokens to be worth ranking on.
|
|
23
|
+
// Below it we treat the prompt as anaphora and inject nothing.
|
|
24
|
+
const MIN_QUERY_TOKENS = 2;
|
|
25
|
+
|
|
26
|
+
// Default token budget for the whole tier-2 injection. Deliberately small: it rides on
|
|
27
|
+
// EVERY prompt, and it must never starve the always-on SessionStart digest.
|
|
28
|
+
const DEFAULT_TOKEN_BUDGET = 400;
|
|
29
|
+
|
|
30
|
+
// Default relevance floor for the live hook path so it never runs floor-less. bm25 is
|
|
31
|
+
// more-negative-is-better, so 0 keeps every token-AND FTS match: the ACTIVE relevance gate
|
|
32
|
+
// today is the token-AND match plus the anaphora guard, and the numeric floor is a knob to
|
|
33
|
+
// be tightened (made negative) once shadow-logging measurement (781f6f65 step 0) shows where
|
|
34
|
+
// the corpus's relevant/irrelevant boundary sits. Named + wired so the default is explicit,
|
|
35
|
+
// not an accidental `undefined`.
|
|
36
|
+
const DEFAULT_SCORE_FLOOR = 0;
|
|
37
|
+
|
|
38
|
+
// Short/function words that carry no retrieval signal. Not exhaustive — just enough to
|
|
39
|
+
// stop pure anaphora ("do that now", "same for it") from clearing the guard.
|
|
40
|
+
const STOPWORDS = new Set([
|
|
41
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'for', 'to', 'of', 'in', 'on', 'at', 'by', 'is',
|
|
42
|
+
'it', 'this', 'that', 'these', 'those', 'do', 'did', 'now', 'then', 'same', 'again',
|
|
43
|
+
'continue', 'go', 'ok', 'okay', 'yes', 'no', 'fix', 'please', 'thanks', 'with', 'as',
|
|
44
|
+
'we', 'i', 'you', 'he', 'she', 'they', 'them', 'his', 'her', 'my', 'our', 'your',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
// Rough token estimate: ~4 chars/token, matching lib/memory-digest.js's convention so
|
|
48
|
+
// the two tiers budget on the same scale.
|
|
49
|
+
function estimateTokens(text) {
|
|
50
|
+
return Math.ceil(String(text || '').length / 4);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Parse the JSON payload Claude Code delivers on a UserPromptSubmit hook's stdin. Never
|
|
55
|
+
* throws — any malformed input yields an empty prompt so the hook fails open.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} raw
|
|
58
|
+
* @returns {{ prompt: string, sessionId: (string|null) }}
|
|
59
|
+
*/
|
|
60
|
+
function parseHookInput(raw) {
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(raw);
|
|
63
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
64
|
+
return { prompt: '', sessionId: null };
|
|
65
|
+
}
|
|
66
|
+
const prompt = typeof parsed.prompt === 'string' ? parsed.prompt : '';
|
|
67
|
+
const sessionId = typeof parsed.session_id === 'string' ? parsed.session_id : null;
|
|
68
|
+
return { prompt, sessionId };
|
|
69
|
+
} catch {
|
|
70
|
+
return { prompt: '', sessionId: null };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Distinct content tokens in a query — lowercased, length >= 3, minus stopwords. The
|
|
76
|
+
* anaphora guard counts these; the FTS layer does its own tokenization for the actual match.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} query
|
|
79
|
+
* @returns {string[]}
|
|
80
|
+
*/
|
|
81
|
+
function meaningfulTokens(query) {
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
// Unicode-aware split, matching the FTS tokenizer (/[\p{L}\p{N}]+/gu in the kernel driver)
|
|
84
|
+
// so non-Latin prompts (Cyrillic/CJK/accented) aren't silently stripped — otherwise the
|
|
85
|
+
// anaphora guard would disable recall for every non-Latin-script user.
|
|
86
|
+
for (const rawToken of String(query || '').toLowerCase().split(/[^\p{L}\p{N}]+/u)) {
|
|
87
|
+
if (!rawToken) continue;
|
|
88
|
+
if (STOPWORDS.has(rawToken)) continue;
|
|
89
|
+
// The length>=3 filter suppresses ASCII noise ("it", "do"), but CJK words are 1-2 chars
|
|
90
|
+
// and any non-ASCII token is inherently content — keep those regardless of length.
|
|
91
|
+
if (rawToken.length < 3 && /^[a-z0-9]+$/.test(rawToken)) continue;
|
|
92
|
+
seen.add(rawToken);
|
|
93
|
+
}
|
|
94
|
+
return [...seen];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Choose which memories to inject this turn. PURE.
|
|
99
|
+
*
|
|
100
|
+
* @param {object} args
|
|
101
|
+
* @param {string} args.query — the submitted prompt
|
|
102
|
+
* @param {Array<{key:string, value:string, score:number}>} args.hits — bm25-ordered
|
|
103
|
+
* (best/lowest score first), already relevance-only (token-AND matched)
|
|
104
|
+
* @param {number} [args.scoreFloor] — keep only hits with score <= floor (more negative =
|
|
105
|
+
* stronger). Omit/null to rely on the FTS match alone. The VALUE is corpus-dependent and
|
|
106
|
+
* should be tuned from shadow-logging measurement, not guessed — this is the knob.
|
|
107
|
+
* @param {number} [args.tokenBudget]
|
|
108
|
+
* @param {string[]} [args.excludeKeys] — keys injected on recent turns (cross-turn dedupe)
|
|
109
|
+
* @returns {{ lines: string[], injectedKeys: string[] }}
|
|
110
|
+
*/
|
|
111
|
+
function selectInjection({ query, hits, scoreFloor = null, tokenBudget = DEFAULT_TOKEN_BUDGET, excludeKeys = [] }) {
|
|
112
|
+
// Anaphora guard: a query with too little signal ranks garbage — stay silent.
|
|
113
|
+
if (meaningfulTokens(query).length < MIN_QUERY_TOKENS) {
|
|
114
|
+
return { lines: [], injectedKeys: [] };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const exclude = new Set(excludeKeys || []);
|
|
118
|
+
const lines = [];
|
|
119
|
+
const injectedKeys = [];
|
|
120
|
+
let spent = 0;
|
|
121
|
+
|
|
122
|
+
for (const hit of hits || []) {
|
|
123
|
+
if (!hit || typeof hit.key !== 'string') continue;
|
|
124
|
+
if (exclude.has(hit.key)) continue;
|
|
125
|
+
// Relevance floor: below the bar contributes nothing. bm25 is more-negative-is-better.
|
|
126
|
+
if (typeof scoreFloor === 'number' && !(typeof hit.score === 'number' && hit.score <= scoreFloor)) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const body = String(hit.value == null ? '' : hit.value);
|
|
130
|
+
const cost = estimateTokens(body);
|
|
131
|
+
if (spent + cost > tokenBudget) {
|
|
132
|
+
// Budget exhausted; stop rather than skip-and-continue so the strongest fit.
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
lines.push(body);
|
|
136
|
+
injectedKeys.push(hit.key);
|
|
137
|
+
spent += cost;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { lines, injectedKeys };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = {
|
|
144
|
+
MIN_QUERY_TOKENS,
|
|
145
|
+
DEFAULT_TOKEN_BUDGET,
|
|
146
|
+
DEFAULT_SCORE_FLOOR,
|
|
147
|
+
estimateTokens,
|
|
148
|
+
parseHookInput,
|
|
149
|
+
meaningfulTokens,
|
|
150
|
+
selectInjection,
|
|
151
|
+
};
|