@staix/agent-mem 0.22.1 → 0.23.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/.claude-plugin/plugin.json +1 -1
- package/README.md +59 -35
- package/package.json +4 -3
- package/src/cli.ts +10 -1
- package/src/hook-jcode-journal.ts +153 -0
- package/src/hook-jcode.ts +13 -0
- package/src/hook-record.ts +91 -0
- package/src/hook-spool-replay.ts +10 -1
- package/src/hook-spool.ts +43 -10
- package/src/hook-transcript.ts +68 -4
- package/src/hooks.ts +36 -11
- package/src/index.ts +4 -0
- package/src/memory/capture-input.ts +10 -1
- package/src/memory/capture.ts +42 -2
- package/src/memory/config.ts +9 -2
- package/src/memory/metrics.ts +4 -3
- package/src/memory/private-spans.ts +6 -3
- package/src/memory/repo-scope.ts +7 -1
- package/src/memory/search-score.ts +4 -2
- package/src/memory/sync-placement-prerequisites.ts +8 -1
- package/src/session-brief-worker.ts +1 -1
- package/src/session-brief.ts +21 -0
- package/src/sync-placement-writer-context.ts +31 -7
package/src/memory/capture.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
4
4
|
import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, opendirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { looksSecret } from "./screen.ts";
|
|
7
|
-
import { captureAttribution, normalizeCaptureFields, SESSION_KEY, type CaptureFields } from "./capture-input.ts";
|
|
7
|
+
import { captureAttribution, normalizeCaptureFields, RecordNormalizationRefusal, SESSION_KEY, type CaptureFields } from "./capture-input.ts";
|
|
8
8
|
export { CAPTURE_MAX_EXCERPT, CAPTURE_MAX_PATHS } from "./capture-input.ts";
|
|
9
9
|
import { memoryBase } from "./home.ts";
|
|
10
10
|
import { captureShardDir, ensureShardDescriptor, readShardDescriptor, resolveAlias, shardKeyHash } from "./shard.ts";
|
|
@@ -292,6 +292,7 @@ export class CaptureStore {
|
|
|
292
292
|
// #173: additive nullable columns. Older readers use explicit or `SELECT *` column names and ignore extras.
|
|
293
293
|
addAttributionColumns(db);
|
|
294
294
|
this.ensureBriefSummaryIndex(true);
|
|
295
|
+
this.ensureBriefRecordIndex(true);
|
|
295
296
|
this.ensureFts();
|
|
296
297
|
} catch (error) {
|
|
297
298
|
if (!lockError(error)) { db.close(); throw error; }
|
|
@@ -390,7 +391,7 @@ export class CaptureStore {
|
|
|
390
391
|
private briefReady = false;
|
|
391
392
|
private prepareBriefSummaryIndex(): void {
|
|
392
393
|
if (this.readOnly || this.briefReady) return;
|
|
393
|
-
try { this.ensureBriefSummaryIndex(false); this.briefReady = true; }
|
|
394
|
+
try { this.ensureBriefSummaryIndex(false); this.ensureBriefRecordIndex(false); this.briefReady = true; }
|
|
394
395
|
catch { /* Optional summary coverage remains partial; query engines remain independent. */ }
|
|
395
396
|
}
|
|
396
397
|
/** One-time MCP read cost only: hooks neither create this derived index nor scan history to fill it. */
|
|
@@ -437,6 +438,11 @@ export class CaptureStore {
|
|
|
437
438
|
const receipt = options.receipt;
|
|
438
439
|
if (receipt !== undefined && (typeof receipt !== "string" || !/^[a-f0-9]{64}$/.test(receipt) || input.at === undefined)) throw new Error("capture receipt needs a stable time and a 64-hex digest");
|
|
439
440
|
const row = this.normalize(input);
|
|
441
|
+
// Pending hook Records must stay whole when caps or screening change before replay.
|
|
442
|
+
// Check before receipt derivation or any spool/DB write; drainers retire this policy refusal whole.
|
|
443
|
+
if (input.event === "Record" && (row.excerpt !== input.excerpt || JSON.stringify(row.paths) !== JSON.stringify(input.paths ?? []))) {
|
|
444
|
+
throw new RecordNormalizationRefusal();
|
|
445
|
+
}
|
|
440
446
|
// Reuse the immutable spool format and its existing unique index: a crash between capture and cursor
|
|
441
447
|
// advance replays exactly the same screened line, whether first written directly or under contention.
|
|
442
448
|
const spoolId = receipt === undefined ? null : createHash("sha256").update(this.spoolLine(row, receipt)).digest("hex");
|
|
@@ -661,6 +667,40 @@ export class CaptureStore {
|
|
|
661
667
|
if (bounded) this.db.transaction(build).immediate(); else build();
|
|
662
668
|
}
|
|
663
669
|
|
|
670
|
+
/** Share the summary index's atomic small-store and off-hook preparation rules. */
|
|
671
|
+
private ensureBriefRecordIndex(bounded: boolean): void {
|
|
672
|
+
if (this.db.query("SELECT 1 FROM sqlite_master WHERE name='rows_brief_records_v1' AND type='index'").get()) return;
|
|
673
|
+
const build = () => {
|
|
674
|
+
if (bounded) {
|
|
675
|
+
const count = (this.db.query("SELECT COUNT(*) AS n FROM (SELECT 1 FROM rows LIMIT 129)").get() as { n: number }).n;
|
|
676
|
+
if (count > 128) return;
|
|
677
|
+
}
|
|
678
|
+
this.db.run("CREATE INDEX IF NOT EXISTS rows_brief_records_v1 ON rows(project,at,id) WHERE event='Record' AND (subagent IS NULL OR subagent != 1)");
|
|
679
|
+
};
|
|
680
|
+
if (bounded) this.db.transaction(build).immediate(); else build();
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** A separate derived index leaves the released summary predicate unchanged. */
|
|
684
|
+
readBriefRecords(limit = BRIEF_SUMMARY_CANDIDATES): { rows: CaptureRow[]; partial: boolean } {
|
|
685
|
+
this.checkReadBinding();
|
|
686
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > BRIEF_SUMMARY_CANDIDATES) throw new Error("invalid brief Record limit; use an integer from 1 to 32");
|
|
687
|
+
const indexed = !!this.db.query("SELECT 1 FROM sqlite_master WHERE name='rows_brief_records_v1' AND type='index'").get();
|
|
688
|
+
let rows: RawRow[], partial = false;
|
|
689
|
+
if (indexed) {
|
|
690
|
+
rows = this.db.query("SELECT * FROM rows INDEXED BY rows_brief_records_v1 WHERE project=? AND event='Record' AND (subagent IS NULL OR subagent != 1) ORDER BY at DESC,id DESC LIMIT ?")
|
|
691
|
+
.all(this.key, limit + 1) as RawRow[];
|
|
692
|
+
} else {
|
|
693
|
+
// ponytail: inspect only 128 recent legacy rows until off-hook search prepares
|
|
694
|
+
// the index; an explicit bounded index-preparation command is the upgrade.
|
|
695
|
+
const hasSubagent = (this.db.query("PRAGMA table_info(rows)").all() as { name: string }[]).some(column => column.name === "subagent");
|
|
696
|
+
const subagent = hasSubagent ? " AND (subagent IS NULL OR subagent != 1)" : "";
|
|
697
|
+
rows = this.db.query(`SELECT * FROM (SELECT * FROM rows INDEXED BY rows_at ORDER BY at DESC,id DESC LIMIT 128) WHERE project=? AND event='Record'${subagent} ORDER BY at DESC,id DESC LIMIT ?`)
|
|
698
|
+
.all(this.key, limit + 1) as RawRow[];
|
|
699
|
+
partial = this.db.query("SELECT id FROM rows INDEXED BY rows_at ORDER BY at DESC,id DESC LIMIT 129").all().length > 128;
|
|
700
|
+
}
|
|
701
|
+
return { rows: rows.slice(0, limit).map(toRow), partial: partial || rows.length > limit };
|
|
702
|
+
}
|
|
703
|
+
|
|
664
704
|
/** Index reads return at most33 bodies. Legacy fallback reads at most128 recent rows. */
|
|
665
705
|
readBriefSummaries(limit = BRIEF_SUMMARY_CANDIDATES): { rows: CaptureRow[]; partial: boolean } {
|
|
666
706
|
this.checkReadBinding();
|
package/src/memory/config.ts
CHANGED
|
@@ -17,10 +17,12 @@ export const CONFIG_FILE = "amem.config.json";
|
|
|
17
17
|
export const REPO_CONFIG_DIR = ".amem";
|
|
18
18
|
export const REPO_CONFIG_FILE = "config";
|
|
19
19
|
const MAX_CONFIG_BYTES = 64 * 1024;
|
|
20
|
-
const MACHINE_KEYS = ["home", "denyPaths", "piiPatterns", "briefBytes", "excerptBytes", "capturePaths", "capture"] as const;
|
|
21
|
-
const REPO_KEYS = ["denyPaths", "piiPatterns", "briefBytes", "excerptBytes", "capturePaths"] as const;
|
|
20
|
+
const MACHINE_KEYS = ["home", "denyPaths", "piiPatterns", "briefBytes", "excerptBytes", "capturePaths", "capture", "records"] as const;
|
|
21
|
+
const REPO_KEYS = ["denyPaths", "piiPatterns", "briefBytes", "excerptBytes", "capturePaths", "records"] as const;
|
|
22
22
|
|
|
23
23
|
export interface AmemConfig {
|
|
24
|
+
/** Static SessionStart record instruction; absent means enabled. Repository false only narrows. */
|
|
25
|
+
records?: boolean;
|
|
24
26
|
/** The memory home, when the file moves it (a pointer file: see `configuredHome`). `AMEM_HOME` still wins. Machine file only. */
|
|
25
27
|
home?: string;
|
|
26
28
|
denyPaths: string[];
|
|
@@ -77,6 +79,10 @@ function parseFile(raw: unknown, label: string, keys: readonly string[]): Partia
|
|
|
77
79
|
const out: Partial<AmemConfig> & { capture?: AmemConfig["capture"] } = {
|
|
78
80
|
denyPaths: listOf(body.denyPaths, "denyPaths", label), piiPatterns: listOf(body.piiPatterns, "piiPatterns", label),
|
|
79
81
|
};
|
|
82
|
+
if (Object.hasOwn(body, "records")) {
|
|
83
|
+
if (typeof body.records !== "boolean") return fail(label, "records is not a boolean");
|
|
84
|
+
out.records = body.records;
|
|
85
|
+
}
|
|
80
86
|
const briefBytes = countOf(body.briefBytes, "briefBytes", label, 1_000_000); if (briefBytes !== undefined) out.briefBytes = briefBytes;
|
|
81
87
|
const excerptBytes = countOf(body.excerptBytes, "excerptBytes", label, 100_000); if (excerptBytes !== undefined) out.excerptBytes = excerptBytes;
|
|
82
88
|
const capturePaths = countOf(body.capturePaths, "capturePaths", label, 1000); if (capturePaths !== undefined) out.capturePaths = capturePaths;
|
|
@@ -149,6 +155,7 @@ export function loadAmemConfig(base: string, repoRoot?: string): LoadedConfig {
|
|
|
149
155
|
if (piiPatterns.length > 64) fail(machinePath, "piiPatterns and the repository's piiPatterns name more than 64 entries together");
|
|
150
156
|
const config: AmemConfig = { denyPaths, piiPatterns, capture: { ...(machine?.capture ?? {}) } };
|
|
151
157
|
if (machine?.home !== undefined) config.home = machine.home;
|
|
158
|
+
if (machine?.records !== undefined || repo?.records !== undefined) config.records = machine?.records !== false && repo?.records !== false;
|
|
152
159
|
for (const key of ["briefBytes", "excerptBytes", "capturePaths"] as const) { const value = lower(machine?.[key], repo?.[key]); if (value !== undefined) config[key] = value; }
|
|
153
160
|
return { config, source: { machine: machineRaw === undefined ? null : machinePath, repo: repoPath } };
|
|
154
161
|
}
|
package/src/memory/metrics.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { closeSync, constants, fstatSync, openSync, readSync, appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import type { RecordDropReason } from "../hook-record.ts";
|
|
2
3
|
import type { HookRssSources } from "../hook-resources.ts";
|
|
3
4
|
import type { HookTranscriptStopReason } from "../hook-contract.ts";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
|
|
6
7
|
/** Rolling resource samples; importing this leaf never loads a memory store or capacity probe. */
|
|
7
|
-
export interface MetricSample { at: string; kind: "hook" | "search"; wallMs?: number; rssMB?: number; rawRssMB?: number; rssSources?: HookRssSources; rssScope?: "owned-images"; transcriptOffset?: number; transcriptStopReason?: HookTranscriptStopReason; /** #226: owned children killed but still alive when the hook returned (bounded reap). */ abandonedChildren?: number; /** #226: a killed worker skipped this call's drain; staged records stay pending (not a capture drop). */ drainSkipped?: number; /** #226: when the worker drain started (ms after hook start) and how many records it committed. */ drainStartMs?: number; drained?: number; idleMB?: number; captureDrops?: number; captureDropReason?: CaptureDropReason }
|
|
8
|
+
export interface MetricSample { at: string; kind: "hook" | "search"; wallMs?: number; rssMB?: number; rawRssMB?: number; rssSources?: HookRssSources; rssScope?: "owned-images"; transcriptOffset?: number; transcriptStopReason?: HookTranscriptStopReason; /** #226: owned children killed but still alive when the hook returned (bounded reap). */ abandonedChildren?: number; /** #226: a killed worker skipped this call's drain; staged records stay pending (not a capture drop). */ drainSkipped?: number; /** #226: when the worker drain started (ms after hook start) and how many records it committed. */ drainStartMs?: number; drained?: number; idleMB?: number; recordInstructionBytes?: number; recordInstructionEstimatedTokens?: number; captureDrops?: number; captureDropReason?: CaptureDropReason }
|
|
8
9
|
const METRICS_MAX_BYTES = 64 * 1024, METRICS_KEEP = 200;
|
|
9
10
|
|
|
10
11
|
export function metricsPath(base: string): string {
|
|
@@ -38,8 +39,8 @@ export function readMetrics(base: string, limit = METRICS_KEEP): MetricSample[]
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
|
|
41
|
-
export type CaptureDropReason = "summary_capacity" | "scope_capacity" | "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten" | "unknown_schema_expired" | "transcript_line_bound";
|
|
42
|
-
const DROP_REASONS: readonly CaptureDropReason[] = ["summary_capacity", "scope_capacity", "spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten", "unknown_schema_expired", "transcript_line_bound"];
|
|
42
|
+
export type CaptureDropReason = "summary_capacity" | "scope_capacity" | "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown" | "project_archived" | "project_forgotten" | "unknown_schema_expired" | "transcript_line_bound" | RecordDropReason;
|
|
43
|
+
const DROP_REASONS: readonly CaptureDropReason[] = ["summary_capacity", "scope_capacity", "spool_full", "cwd_removed", "undrainable_expired", "scope_unknown", "project_archived", "project_forgotten", "unknown_schema_expired", "transcript_line_bound", "record_invalid", "record_count", "record_length", "record_policy", "record_identity", "record_timestamp"];
|
|
43
44
|
/** Numeric notice only, bounded to the rolling metric tail; never an all-time counter. Fails open to no counts: hooks never deny over metrics. */
|
|
44
45
|
export function captureDropSummary(base: string): Partial<Record<CaptureDropReason, number>> {
|
|
45
46
|
const read = readCaptureDrops(base);
|
|
@@ -9,17 +9,20 @@
|
|
|
9
9
|
*/
|
|
10
10
|
const TAG = /<\s*(\/?)\s*private(?=[\s/>])[^<>]*>/gi;
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/** The optional range observer receives only omitted offsets, never private text. */
|
|
13
|
+
export function stripPrivateSpans(text: string, omitted?: (start: number, end: number) => void): string {
|
|
13
14
|
if (!/<\s*\/?\s*private(?=[\s/>])/i.test(text)) return text;
|
|
14
|
-
let out = "", depth = 0, last = 0, match: RegExpExecArray | null;
|
|
15
|
+
let out = "", depth = 0, last = 0, privateStart = 0, match: RegExpExecArray | null;
|
|
15
16
|
TAG.lastIndex = 0;
|
|
16
17
|
while ((match = TAG.exec(text))) {
|
|
17
18
|
const closing = match[1] === "/", stray = closing && depth === 0;
|
|
18
19
|
if (depth === 0) out += text.slice(last, stray ? match.index + match[0].length : match.index); // a stray closer is plain text: keep it
|
|
19
|
-
if (closing) { if (depth > 0
|
|
20
|
+
if (closing) { if (depth > 0 && --depth === 0) omitted?.(privateStart, match.index + match[0].length); }
|
|
21
|
+
else { if (depth === 0) privateStart = match.index; depth++; }
|
|
20
22
|
last = match.index + match[0].length;
|
|
21
23
|
}
|
|
22
24
|
if (depth === 0) out += text.slice(last);
|
|
25
|
+
else omitted?.(privateStart, text.length);
|
|
23
26
|
return out;
|
|
24
27
|
}
|
|
25
28
|
|
package/src/memory/repo-scope.ts
CHANGED
|
@@ -284,8 +284,14 @@ export function namesPrivatePath(text: string): boolean {
|
|
|
284
284
|
return HOME_PATH.some(re => re.test(folded)) || (home.length > 3 && folded.includes(home));
|
|
285
285
|
}
|
|
286
286
|
const SAFE_NODE = /^(?:path|symbol|issue):/;
|
|
287
|
-
|
|
287
|
+
// Offline syntax only (#185): no lookup. DOI `10.<registrant>/<suffix>` with printable, space-free ASCII suffix; arXiv new (`YYMM.NNNN[N]`) and old (`archive[.XX]/YYMMNNN`) ids, optional `vN`.
|
|
288
|
+
const DOI_NODE = /^doi:10\.\d{4,9}(?:\.\d+)*\/[\x21-\x7e]+$/;
|
|
289
|
+
const ARXIV_NODE = /^arxiv:(?:\d{2}(?:0[1-9]|1[0-2])\.\d{4,5}|[a-z]+(?:-[a-z]+)*(?:\.[A-Z]{2})?\/\d{2}(?:0[1-9]|1[0-2])\d{3})(?:v[1-9]\d*)?$/;
|
|
290
|
+
/** A public literature identifier (`doi:` or `arxiv:`) that is well formed and so may be committed to repo scope. */
|
|
291
|
+
export function publicIdentifierNode(node: string): boolean { return DOI_NODE.test(node) || ARXIV_NODE.test(node); }
|
|
292
|
+
/** A graph node that may be committed: a repository-relative path or symbol, an issue, or a well-formed public `doi:`/`arxiv:` identifier. Private task, goal, memory and host-local (`run:`) references, malformed identifiers and absolute or escaping paths stay out. */
|
|
288
293
|
function committableNode(node: string): boolean {
|
|
294
|
+
if (node.startsWith("doi:") || node.startsWith("arxiv:")) return publicIdentifierNode(node);
|
|
289
295
|
if (!SAFE_NODE.test(node)) return false;
|
|
290
296
|
const where = node.startsWith("path:") ? node.slice(5) : node.startsWith("symbol:") && node.includes("@") ? node.slice(node.lastIndexOf("@") + 1) : "";
|
|
291
297
|
if (!where) return !node.startsWith("path:") && !node.startsWith("symbol:");
|
|
@@ -14,16 +14,18 @@ type ScoreExpr = { op: "rank" | "tier" | "age" } | { op: "constant"; value: numb
|
|
|
14
14
|
const MONTH_DAYS = 30;
|
|
15
15
|
const TIER_WEIGHTS: Readonly<Record<string, number>> = { long: 1, capture: 0.5 };
|
|
16
16
|
const DEFAULT_TIER_WEIGHT = 0.9;
|
|
17
|
-
// ponytail: fixed 1.25x
|
|
17
|
+
// ponytail: fixed 1.25x conversation and 1.5x Record weights, not a learned relevance model.
|
|
18
18
|
// Raise it only with fresh S/M no-loss and private-capture evidence; curated tier factors stay unchanged.
|
|
19
19
|
export const CAPTURE_CONVERSATION_WEIGHT = 1.25;
|
|
20
|
+
const CAPTURE_RECORD_WEIGHT = 1.5;
|
|
20
21
|
const CONVERSATIONAL_CAPTURE_EVENTS = ["Stop", "UserPromptSubmit", "Title", "Recap", "Compact"] as const;
|
|
21
22
|
export function captureEventWeight(event: string): number {
|
|
23
|
+
if (event === "Record") return CAPTURE_RECORD_WEIGHT;
|
|
22
24
|
return (CONVERSATIONAL_CAPTURE_EVENTS as readonly string[]).includes(event) ? CAPTURE_CONVERSATION_WEIGHT : 1;
|
|
23
25
|
}
|
|
24
26
|
/** Only fixed internal SQL identifiers are supplied by callers; event values remain stored data. */
|
|
25
27
|
export function captureEventWeightSql(eventColumn: string): string {
|
|
26
|
-
return `(CASE WHEN ${eventColumn} IN (${CONVERSATIONAL_CAPTURE_EVENTS.map(event => `'${event}'`).join(", ")}) THEN ${CAPTURE_CONVERSATION_WEIGHT} ELSE 1.0 END)`;
|
|
28
|
+
return `(CASE WHEN ${eventColumn} = 'Record' THEN ${CAPTURE_RECORD_WEIGHT} WHEN ${eventColumn} IN (${CONVERSATIONAL_CAPTURE_EVENTS.map(event => `'${event}'`).join(", ")}) THEN ${CAPTURE_CONVERSATION_WEIGHT} ELSE 1.0 END)`;
|
|
27
29
|
}
|
|
28
30
|
const TIER_FACTOR: ScoreExpr = { op: "tier" };
|
|
29
31
|
const DENOMINATOR: ScoreExpr = { op: "add", left: { op: "constant", value: 1 }, right: { op: "max", left: { op: "constant", value: 0 }, right: { op: "age" } } };
|
|
@@ -9,6 +9,13 @@ import { PLACEMENT_PROFILE_SCHEMA, validatePlacementBaseV2, type PlacementBaseV2
|
|
|
9
9
|
|
|
10
10
|
export const PLACEMENT_ACTIVATION_MIN_READER = "0.21.0";
|
|
11
11
|
const preview = "amem migrate --sync-placement --dry-run";
|
|
12
|
+
/** Typed only for safe adapter diagnostics; the floor predicate and remedy stay shared. */
|
|
13
|
+
export class PlacementHomeFloorError extends Error {
|
|
14
|
+
constructor(minReader: string) {
|
|
15
|
+
super(`Sync placement requires a person-authored home floor of ${minReader} or later in formats.json; have the person append that record, then rerun ${preview}.`);
|
|
16
|
+
this.name = "PlacementHomeFloorError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
12
19
|
function requireHomeFloor(home: string,minReader:string): string {
|
|
13
20
|
assertFormatFloor(home, "curated"); assertFormatFloor(home, "sync");
|
|
14
21
|
const floors = readFormatFloors(home), minimum = minReader.split(".").map(Number);
|
|
@@ -18,7 +25,7 @@ function requireHomeFloor(home: string,minReader:string): string {
|
|
|
18
25
|
for (let i = 0; i < 3; i++) if (parts[i] !== minimum[i]) return parts[i]! > minimum[i]!;
|
|
19
26
|
return true;
|
|
20
27
|
});
|
|
21
|
-
if (!adequate) throw
|
|
28
|
+
if (!adequate) throw new PlacementHomeFloorError(minReader);
|
|
22
29
|
return hashJSON(floors);
|
|
23
30
|
}
|
|
24
31
|
export function requirePlacementHomeFloor(home:string):string{return requireHomeFloor(home,PLACEMENT_ACTIVATION_MIN_READER);}
|
|
@@ -121,7 +121,7 @@ if (import.meta.main) {
|
|
|
121
121
|
else try {
|
|
122
122
|
const { scanHookTranscriptTail } = await import("./hook-transcript.ts");
|
|
123
123
|
const scanned = await scanHookTranscriptTail(scan.transcript, {
|
|
124
|
-
cwd: scan.cwd, base: scan.base, policy: scan.policy, deadline: performance.now() + (scanEnd - now()),
|
|
124
|
+
cwd: scan.cwd, base: scan.base, policy: scan.policy, workBudgetMs: 60, deadline: performance.now() + (scanEnd - now()),
|
|
125
125
|
// Same existing oldest-first drain and one shared budget, never a new 32-file allowance per publication.
|
|
126
126
|
onPublication: async () => { await replay(scanEnd, false); },
|
|
127
127
|
onBackpressure: () => replay(scanEnd, false),
|
package/src/session-brief.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { lstatSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { CaptureRow } from "./memory/capture.ts";
|
|
4
|
+
import { parseRecordLines } from "./hook-record.ts";
|
|
4
5
|
import { readSelector } from "./memory/generation.ts";
|
|
5
6
|
import { memoryHome } from "./memory/home.ts";
|
|
6
7
|
import { canonicalPath, realPath } from "./memory/platform.ts";
|
|
@@ -99,6 +100,14 @@ function captureDisplay(row: CaptureRow): { data: unknown; partial: boolean } {
|
|
|
99
100
|
partial: excerpt.partial || event.partial || row.paths.length > paths.length || paths.some(path => path.partial) };
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
/** Keep the canonical Record whole; '-' is valid only for project-bound decisions/contracts. */
|
|
104
|
+
function recordDisplay(row: CaptureRow): { data: unknown; partial: boolean } | undefined {
|
|
105
|
+
const parsed = parseRecordLines(row.excerpt), record = parsed.records[0];
|
|
106
|
+
if (!record || parsed.records.length !== 1 || Object.keys(parsed.drops).length || record.line !== row.excerpt
|
|
107
|
+
|| JSON.stringify(record.anchors) !== JSON.stringify(row.paths)) return;
|
|
108
|
+
return { data: { event: "Record", paths: record.anchors, excerpt: record.line }, partial: false };
|
|
109
|
+
}
|
|
110
|
+
|
|
102
111
|
/** A short-lived, model-free projection. The hook host supplies the hard process deadline; this function owns no timer or child. */
|
|
103
112
|
export async function sessionStartBrief(options: BriefOptions): Promise<BriefResult> {
|
|
104
113
|
let result = unavailable();
|
|
@@ -204,6 +213,8 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
|
|
|
204
213
|
if (section === "capture" && skipCaptureIds.has((row as { id: number }).id)) continue;
|
|
205
214
|
// #173: subagent capture stays searchable but never enters the session brief.
|
|
206
215
|
if (section === "capture" && (row as { subagent?: unknown }).subagent === true) continue;
|
|
216
|
+
// Records have their own five-row, whole-line selection before raw captures.
|
|
217
|
+
if (section === "capture" && (row as { event?: unknown }).event === "Record") continue;
|
|
207
218
|
if (!await screen(row)) continue;
|
|
208
219
|
if (selected++ >= SECTION_RECORDS) { coverage[section] = "partial"; return; }
|
|
209
220
|
await add(section, display(row), section === "curated");
|
|
@@ -250,6 +261,16 @@ export async function sessionStartBrief(options: BriefOptions): Promise<BriefRes
|
|
|
250
261
|
}
|
|
251
262
|
const capture = new CaptureStore({ memoryDir: base, project, cwd, ...(options.nativeSession || options.measureOnly ? { readOnly: true, busyTimeoutMs: options.measureOnly ? 1000 : 0, maxReadRows: 128, recentReadRows: true } : {}) }); closers.push(() => capture.close());
|
|
252
263
|
if (capture.recentReadTruncated) coverage.capture = "partial";
|
|
264
|
+
const records = capture.readBriefRecords();
|
|
265
|
+
if (records.partial) coverage.capture = "partial";
|
|
266
|
+
let selectedRecords = 0;
|
|
267
|
+
for (const row of records.rows) {
|
|
268
|
+
if (row.project !== project || row.subagent === true) continue;
|
|
269
|
+
if (selectedRecords >= 5) { coverage.capture = "partial"; break; }
|
|
270
|
+
if (!await screen(row)) continue;
|
|
271
|
+
const shown = recordDisplay(row);
|
|
272
|
+
if (shown && await add("capture", shown, false)) selectedRecords++;
|
|
273
|
+
}
|
|
253
274
|
const summaries = capture.readBriefSummaries(), summaryIds = new Set<number>();
|
|
254
275
|
if (summaries.partial) coverage.capture = "partial";
|
|
255
276
|
let selectedSummaries = 0;
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
/** CLI adapter
|
|
1
|
+
/** CLI/host adapter. Expected locators are never returned as fresh writer facts. */
|
|
2
2
|
import { types } from "node:util";
|
|
3
|
-
import {
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { canonicalPath, realPath } from "./memory/platform.ts";
|
|
4
5
|
import { canonicalJSON } from "./memory/generation.ts";
|
|
5
6
|
import { readSetupWriterRoots } from "./setup.ts";
|
|
6
7
|
import { measureSyncPlacementWriters, extractSyncPlacementWriterFailure, type SyncPlacementWriterFailureDetail, type SyncPlacementWriterMeasurement } from "./memory/sync-placement-writers.ts";
|
|
7
8
|
import { readEffectiveSyncPlacementWriters } from "./memory/sync-placement-effective-writers.ts";
|
|
8
|
-
import { requirePlacementHomeFloor } from "./memory/sync-placement-prerequisites.ts";
|
|
9
|
+
import { PlacementHomeFloorError, requirePlacementHomeFloor } from "./memory/sync-placement-prerequisites.ts";
|
|
10
|
+
import { FormatFloorError } from "./memory/formats.ts";
|
|
9
11
|
import type { PlacementCapturePort } from "./memory/sync-placement-capture.ts";
|
|
10
12
|
import { SharedWriterRevisionError } from "./memory/sync-placement-writer-revision-format.ts";
|
|
11
13
|
|
|
12
|
-
export type PlacementWriterRootCategory = "running CLI" | "setup receipt" | "declared writer" | "active locator" | "home floor" | "writer renewal";
|
|
14
|
+
export type PlacementWriterRootCategory = "running CLI" | "host package" | "setup receipt" | "declared writer" | "active locator" | "home floor" | "writer renewal";
|
|
13
15
|
export type PlacementWriterContextPhase = "initial" | "active";
|
|
14
16
|
const failureDetails:Record<SyncPlacementWriterFailureDetail,string>={
|
|
15
17
|
"below-minimum":"release is below 0.21.0", "wrong-package":"package is not @staix/agent-mem", "writable-runtime":"runtime code is group- or world-writable", unreadable:"runtime files are unreadable or missing",
|
|
@@ -30,11 +32,12 @@ export class PlacementWriterContextError extends Error {
|
|
|
30
32
|
}
|
|
31
33
|
}
|
|
32
34
|
function safeRefusal(error: unknown, category: PlacementWriterRootCategory,phase:PlacementWriterContextPhase="active"): PlacementWriterContextError {
|
|
35
|
+
if (error && typeof error === "object" && !types.isProxy(error) && (error instanceof PlacementHomeFloorError || error instanceof FormatFloorError)) return new PlacementWriterContextError("home floor","unavailable",phase);
|
|
33
36
|
if(error&&typeof error==="object"&&!types.isProxy(error)&&error instanceof SharedWriterRevisionError){const code=Object.getOwnPropertyDescriptor(error,"code")?.value;if(code==="pending"||code==="staging")return new PlacementWriterContextError("writer renewal","unavailable",phase,undefined,code);}
|
|
34
37
|
if (error && typeof error === "object" && !types.isProxy(error) && error instanceof PlacementWriterContextError) {
|
|
35
38
|
const fields=Object.getOwnPropertyDescriptors(error),root=fields.category,cause=fields.causeClass,detail=fields.detail;
|
|
36
39
|
const category:unknown=root&&"value"in root?root.value:undefined,causeClass:unknown=cause&&"value"in cause?cause.value:undefined;
|
|
37
|
-
if (typeof category==="string" && ["running CLI","setup receipt","declared writer","active locator","home floor","writer renewal"].includes(category)
|
|
40
|
+
if (typeof category==="string" && ["running CLI","host package","setup receipt","declared writer","active locator","home floor","writer renewal"].includes(category)
|
|
38
41
|
&& typeof causeClass==="string" && ["missing","changed","unavailable"].includes(causeClass)) {
|
|
39
42
|
const ownDetail=detail&&"value"in detail&&typeof detail.value==="string"&&Object.hasOwn(failureDetails,detail.value)?detail.value as SyncPlacementWriterFailureDetail:undefined;
|
|
40
43
|
const state=fields.renewalState?.value;
|
|
@@ -93,8 +96,9 @@ export function initialPlacementWriterContext(home: string, roots: readonly stri
|
|
|
93
96
|
/** Active callbacks re-read the bound locator AND measure every package anew. No activation or renewal occurs. */
|
|
94
97
|
export function activePlacementWriterContext(home: string, ownRoot: string): PlacementWriterContext {
|
|
95
98
|
const read = () => {
|
|
96
|
-
requirePlacementHomeFloor(home);
|
|
97
|
-
|
|
99
|
+
try { requirePlacementHomeFloor(home); } catch (error) { throw safeRefusal(error,"home floor"); }
|
|
100
|
+
let effective: ReturnType<typeof readEffectiveSyncPlacementWriters>;
|
|
101
|
+
try { effective=readEffectiveSyncPlacementWriters(home); } catch (error) { throw safeRefusal(error,"active locator"); }
|
|
98
102
|
const roots=effective.roots;if(!roots)return fail("active locator","unavailable");
|
|
99
103
|
const needed = required(home,ownRoot);
|
|
100
104
|
if(!roots.includes(needed.own))return fail("running CLI","missing");
|
|
@@ -113,6 +117,26 @@ export function activePlacementWriterContext(home: string, ownRoot: string): Pla
|
|
|
113
117
|
} }; return context;
|
|
114
118
|
}
|
|
115
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Host sync port bound to this executing installation, never a caller-selected root.
|
|
122
|
+
* Legacy sync ignores the lazy port. Each invoked v3 check re-reads all expected
|
|
123
|
+
* inventory and measures every root again; the cached context is not fresh proof.
|
|
124
|
+
*/
|
|
125
|
+
export function createPlacementAttestation(home: string): PlacementCapturePort["attest"] {
|
|
126
|
+
const base = canonicalPath(home), ownRoot = fileURLToPath(new URL("..", import.meta.url));
|
|
127
|
+
let context: PlacementWriterContext | undefined;
|
|
128
|
+
return () => {
|
|
129
|
+
try {
|
|
130
|
+
context ??= activePlacementWriterContext(base, ownRoot);
|
|
131
|
+
return context.attest();
|
|
132
|
+
} catch (error) {
|
|
133
|
+
const refusal = safeRefusal(error, "active locator");
|
|
134
|
+
if (refusal.category === "running CLI") throw new PlacementWriterContextError("host package", refusal.causeClass, refusal.phase, refusal.detail, refusal.renewalState);
|
|
135
|
+
throw refusal;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
116
140
|
/** Person preview only: incomplete measurements remain explicit and never become an attestation. */
|
|
117
141
|
export function previewActivePlacementWriters(home: string, ownRoot: string, requested: readonly string[] = []) {
|
|
118
142
|
const effective=readEffectiveSyncPlacementWriters(home),mapped=effective.roots;if(!mapped)return fail("active locator","unavailable");
|