@staix/agent-mem 0.6.0 → 0.7.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/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +12 -2
- package/README.md +135 -18
- package/package.json +1 -1
- package/src/cli.ts +476 -39
- package/src/commands-table.ts +53 -15
- package/src/hook-spool.ts +24 -2
- package/src/hooks.ts +9 -5
- package/src/index.ts +14 -0
- package/src/ingest.ts +3 -2
- package/src/mcp-memory.ts +141 -33
- package/src/mcp.ts +42 -7
- package/src/memory/aliases.ts +4 -0
- package/src/memory/bridge.ts +8 -2
- package/src/memory/candidate-pending.ts +119 -0
- package/src/memory/candidates.ts +144 -47
- package/src/memory/capacity-split.ts +76 -0
- package/src/memory/capacity.ts +139 -11
- package/src/memory/capture-input.ts +8 -3
- package/src/memory/capture.ts +30 -5
- package/src/memory/catalog.ts +43 -10
- package/src/memory/commands.ts +98 -44
- package/src/memory/config.ts +167 -0
- package/src/memory/cross-project.ts +75 -0
- package/src/memory/curated-read.ts +29 -0
- package/src/memory/decay-review.ts +136 -0
- package/src/memory/doctor.ts +412 -0
- package/src/memory/editor.ts +58 -0
- package/src/memory/eval-fixture.ts +137 -0
- package/src/memory/eval-set.json +443 -0
- package/src/memory/eval.ts +150 -0
- package/src/memory/gc.ts +97 -0
- package/src/memory/generation-index.ts +22 -17
- package/src/memory/generation-sync.ts +3 -0
- package/src/memory/goal-promotion.ts +71 -0
- package/src/memory/home.ts +15 -4
- package/src/memory/intent-brief.ts +89 -0
- package/src/memory/lifecycle-log.ts +54 -0
- package/src/memory/lifecycle.ts +61 -0
- package/src/memory/metrics.ts +2 -2
- package/src/memory/note-add.ts +100 -0
- package/src/memory/path-glob.ts +94 -0
- package/src/memory/plan-guard.ts +47 -0
- package/src/memory/policy.ts +44 -13
- package/src/memory/private-spans.ts +32 -0
- package/src/memory/project-export.ts +122 -0
- package/src/memory/recall-staleness.ts +56 -0
- package/src/memory/recall.ts +74 -0
- package/src/memory/repo-scope.ts +404 -0
- package/src/memory/shard-sync-format.ts +116 -0
- package/src/memory/shard-sync.ts +421 -0
- package/src/memory/store.ts +282 -27
- package/src/memory/sync-cadence.ts +139 -0
- package/src/memory/sync-screen.ts +86 -0
- package/src/memory/sync.ts +79 -11
- package/src/memory/usage.ts +35 -0
- package/src/memory/use-telemetry.ts +51 -0
- package/src/memory/work-lists.ts +10 -4
- package/src/session-brief-worker.ts +3 -2
- package/src/session-brief.ts +74 -15
- package/src/setup-owned.ts +120 -0
- package/src/setup-toml.ts +291 -0
- package/src/setup.ts +327 -0
package/src/memory/capture.ts
CHANGED
|
@@ -7,7 +7,10 @@ import { normalizeCaptureFields, type CaptureFields } from "./capture-input.ts";
|
|
|
7
7
|
export { CAPTURE_MAX_EXCERPT, CAPTURE_MAX_PATHS } from "./capture-input.ts";
|
|
8
8
|
import { memoryBase } from "./home.ts";
|
|
9
9
|
import { captureShardDir, ensureShardDescriptor, readShardDescriptor, resolveAlias, shardKeyHash } from "./shard.ts";
|
|
10
|
+
import { assertProjectActive } from "./lifecycle.ts";
|
|
10
11
|
import { machineId } from "./machine.ts";
|
|
12
|
+
import { loadAmemConfig, repoConfigRoot } from "./config.ts";
|
|
13
|
+
import { fold } from "./policy.ts";
|
|
11
14
|
import { readWindowSql, readRef, type ReadWindow, type ReadRef, ftsTerms, fts5Available } from "./search-fts.ts";
|
|
12
15
|
|
|
13
16
|
/**
|
|
@@ -81,6 +84,15 @@ export interface CaptureOptions {
|
|
|
81
84
|
recentReadRows?: boolean;
|
|
82
85
|
/** Excerpt screen; defaults to the built-in secret screen. A host passes its stricter policy. */
|
|
83
86
|
screen?: (text: string) => boolean;
|
|
87
|
+
/** Caps a caller may lower (F4); the built-in caps and the person's configured caps are the ceiling, so a larger value is ignored (a host can tighten, never loosen). */
|
|
88
|
+
excerptBytes?: number; capturePaths?: number;
|
|
89
|
+
/** The working directory the capture belongs to: its repository's `.amem/config` narrows the policy (deny paths, caps) for this store. Absent: the machine configuration only. */
|
|
90
|
+
cwd?: string;
|
|
91
|
+
/**
|
|
92
|
+
* #15 T5: a writable open of an archived or forgotten project is refused (the person took it out of recall; `amem project unarchive` brings an archived one back).
|
|
93
|
+
* `ignore` is for the maintenance that must still reach the shard (gc, export): it never appends.
|
|
94
|
+
*/
|
|
95
|
+
lifecycle?: "enforce" | "ignore";
|
|
84
96
|
}
|
|
85
97
|
export interface CaptureAppendOptions {
|
|
86
98
|
/** Opaque stable source receipt for deterministic transcript replay. Requires an explicit source timestamp. */
|
|
@@ -125,13 +137,17 @@ export function writeCaptureConfigFile(path: string, config: Partial<CaptureConf
|
|
|
125
137
|
return next;
|
|
126
138
|
}
|
|
127
139
|
|
|
128
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* The effective R6 settings for a shard directory: the shard's config over the home default (`capture-config.json`, which the capacity advisor writes)
|
|
142
|
+
* over the person's machine configuration (`amem.config.json` `capture`, F4) over the built-in defaults. A malformed machine configuration throws.
|
|
143
|
+
*/
|
|
129
144
|
export function readCaptureConfig(dir: string, base?: string): CaptureConfig {
|
|
130
145
|
const homeDefault = base === undefined ? {} : readCaptureConfigFile(join(base, "capture-config.json"));
|
|
131
146
|
const shard = readCaptureConfigFile(join(dir, "config.json"));
|
|
147
|
+
const machine = base === undefined ? {} : loadAmemConfig(base).config.capture;
|
|
132
148
|
return {
|
|
133
|
-
capMb: shard.capMb ?? homeDefault.capMb ?? CAPTURE_DEFAULT_CAP_MB,
|
|
134
|
-
retentionDays: shard.retentionDays ?? homeDefault.retentionDays ?? CAPTURE_DEFAULT_RETENTION_DAYS,
|
|
149
|
+
capMb: shard.capMb ?? homeDefault.capMb ?? machine.capMb ?? CAPTURE_DEFAULT_CAP_MB,
|
|
150
|
+
retentionDays: shard.retentionDays ?? homeDefault.retentionDays ?? machine.retentionDays ?? CAPTURE_DEFAULT_RETENTION_DAYS,
|
|
135
151
|
};
|
|
136
152
|
}
|
|
137
153
|
|
|
@@ -157,6 +173,7 @@ export class CaptureStore {
|
|
|
157
173
|
private readonly db: Database;
|
|
158
174
|
private readonly now: () => number;
|
|
159
175
|
private readonly screen: (text: string) => boolean;
|
|
176
|
+
private readonly caps: import("./capture-input.ts").CaptureCaps;
|
|
160
177
|
private readonly capMb: number;
|
|
161
178
|
private readonly retentionDays: number;
|
|
162
179
|
/** Set when the schema setup itself met the lock: appends spool without touching the database again. */
|
|
@@ -179,6 +196,7 @@ export class CaptureStore {
|
|
|
179
196
|
if (this.readOnly && !/^[a-f0-9]{16}$/.test(machine)) throw new Error("capture machine identity unavailable");
|
|
180
197
|
this.requestedKey = opts.project;
|
|
181
198
|
this.key = resolveAlias(this.base, opts.project);
|
|
199
|
+
if (!this.readOnly && opts.lifecycle !== "ignore") assertProjectActive(this.base, this.key);
|
|
182
200
|
if (this.readOnly) {
|
|
183
201
|
const descriptor = lstatSync(join(this.base, "projects", shardKeyHash(this.key), "project.json"));
|
|
184
202
|
if (!descriptor.isFile() || descriptor.isSymbolicLink() || descriptor.size > 4096
|
|
@@ -197,7 +215,14 @@ export class CaptureStore {
|
|
|
197
215
|
this.capMb = opts.capMb ?? config.capMb;
|
|
198
216
|
this.retentionDays = opts.retentionDays ?? config.retentionDays;
|
|
199
217
|
this.now = opts.now ?? Date.now;
|
|
200
|
-
this.
|
|
218
|
+
const configured = loadAmemConfig(this.base, opts.cwd === undefined ? undefined : repoConfigRoot(opts.cwd)).config;
|
|
219
|
+
// F4: the person's PII patterns screen every excerpt and their deny paths filter every path, whoever appends (hook, ingest or library). A host's own `screen` replaces only the secret part.
|
|
220
|
+
const patterns = configured.piiPatterns.map(fold).filter(Boolean);
|
|
221
|
+
const base = opts.screen ?? looksSecret;
|
|
222
|
+
this.screen = patterns.length ? (text: string) => base(text) || patterns.some(pattern => fold(text).includes(pattern)) : base;
|
|
223
|
+
const lowest = (a: number | undefined, b: number | undefined) => a === undefined ? b : b === undefined ? a : Math.min(a, b);
|
|
224
|
+
this.caps = { excerptBytes: lowest(opts.excerptBytes, configured.excerptBytes), paths: lowest(opts.capturePaths, configured.capturePaths),
|
|
225
|
+
...(configured.denyPaths.length ? { denyPaths: configured.denyPaths } : {}) };
|
|
201
226
|
const file = join(this.dir, "capture.db");
|
|
202
227
|
if (this.readOnly) {
|
|
203
228
|
// Refuse absent/cold state without authoring machine, shard, schema, FTS or spool metadata.
|
|
@@ -412,7 +437,7 @@ export class CaptureStore {
|
|
|
412
437
|
|
|
413
438
|
/** The one place input becomes a row: caps fields, deny-filters paths, screens the excerpt, counts its bytes. */
|
|
414
439
|
private normalize(input: CaptureInput): NormalizedCapture {
|
|
415
|
-
const { at, agent, event, tool, paths, status, excerpt, bytes } = normalizeCaptureFields(input, () => this.now(), text => this.screen(text));
|
|
440
|
+
const { at, agent, event, tool, paths, status, excerpt, bytes } = normalizeCaptureFields(input, () => this.now(), text => this.screen(text), this.caps);
|
|
416
441
|
return { at, project: input.project, agent, event, tool, paths, status, excerpt, bytes };
|
|
417
442
|
}
|
|
418
443
|
|
package/src/memory/catalog.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import { chmodSync, existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { chmodSync, existsSync, lstatSync, readdirSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { memoryBase } from "./home.ts";
|
|
5
5
|
import { effectiveAliases, readShardDescriptor, SHARDED_SCHEMA } from "./shard.ts";
|
|
6
6
|
import { readProfileJSON, validGeneration } from "./generation.ts";
|
|
7
7
|
import { captureShardStats, readCaptureConfig } from "./capture.ts";
|
|
8
8
|
import { openShardStore } from "./store.ts";
|
|
9
|
+
import { lifecycleResolver, type LifecycleState } from "./lifecycle.ts";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* #15 D3 (storage part only): the user-level catalog of project shards. `catalog.db` in the memory home is derived
|
|
@@ -20,12 +21,16 @@ export interface CatalogProject {
|
|
|
20
21
|
captureRows: number; captureBytes: number; captureLastAt: string | null;
|
|
21
22
|
curatedEntries: number | null; curatedLastAt: string | null; curatedAvailable: boolean;
|
|
22
23
|
capMb: number; retentionDays: number;
|
|
24
|
+
/** #15 T5: the person's lifecycle decision (`lifecycle.jsonl`); forgotten projects stay in the scan so the state is visible, and listings leave them out. */
|
|
25
|
+
state: LifecycleState;
|
|
26
|
+
/** Bytes the shard directory holds on disk (its logs, indexes, capture and work lists), counting files and never following a link. */
|
|
27
|
+
diskBytes: number;
|
|
23
28
|
}
|
|
24
29
|
|
|
25
|
-
interface CatalogRow { hash: string; key: string; local: number; aliases: string; capture_rows: number; capture_bytes: number; capture_last_at: string | null; curated_entries: number | null; curated_last_at: string | null; cap_mb: number; retention_days: number }
|
|
30
|
+
interface CatalogRow { hash: string; key: string; local: number; aliases: string; capture_rows: number; capture_bytes: number; capture_last_at: string | null; curated_entries: number | null; curated_last_at: string | null; cap_mb: number; retention_days: number; state: string; disk_bytes: number }
|
|
26
31
|
|
|
27
32
|
const fromRow = (r: CatalogRow): CatalogProject => ({ hash: r.hash, key: r.key, local: r.local === 1, aliases: JSON.parse(r.aliases) as string[],
|
|
28
|
-
captureRows: r.capture_rows, captureBytes: r.capture_bytes, captureLastAt: r.capture_last_at, curatedEntries: r.curated_entries, curatedLastAt: r.curated_last_at, curatedAvailable: r.curated_entries !== null, capMb: r.cap_mb, retentionDays: r.retention_days });
|
|
33
|
+
captureRows: r.capture_rows, captureBytes: r.capture_bytes, captureLastAt: r.capture_last_at, curatedEntries: r.curated_entries, curatedLastAt: r.curated_last_at, curatedAvailable: r.curated_entries !== null, capMb: r.cap_mb, retentionDays: r.retention_days, state: r.state === "archived" || r.state === "forgotten" ? r.state : "active", diskBytes: r.disk_bytes });
|
|
29
34
|
|
|
30
35
|
/** Interrupted splits expose capture facts, but cannot authorize any curated store open or initialization. */
|
|
31
36
|
function capturedSelection(base: string): boolean {
|
|
@@ -41,7 +46,7 @@ function curatedStats(base: string, hash: string, key: string, captured: boolean
|
|
|
41
46
|
const dir = join(base, "projects", hash);
|
|
42
47
|
// A capture-only descriptor is not permission to initialize a curated store while listing the catalog.
|
|
43
48
|
if (!existsSync(join(dir, "generation.json")) && !existsSync(join(dir, "generation-seal.json"))) return { entries: 0, lastAt: null };
|
|
44
|
-
const store = openShardStore({ memoryDir: base, project: key, deferPromotions: true });
|
|
49
|
+
const store = openShardStore({ memoryDir: base, project: key, deferPromotions: true, lifecycle: "ignore" });
|
|
45
50
|
try {
|
|
46
51
|
const db = new Database(join(dir, "index.db"), { readonly: true });
|
|
47
52
|
try {
|
|
@@ -52,6 +57,23 @@ function curatedStats(base: string, hash: string, key: string, captured: boolean
|
|
|
52
57
|
} finally { store.close(); }
|
|
53
58
|
}
|
|
54
59
|
|
|
60
|
+
/** Bytes of regular files under a shard directory. A link is not followed (counted as nothing), an unreadable entry as nothing: the figure is a lower bound, never a guess. */
|
|
61
|
+
export function directoryBytes(dir: string, depth = 0): number {
|
|
62
|
+
if (depth > 12) return 0;
|
|
63
|
+
let total = 0;
|
|
64
|
+
let entries: import("node:fs").Dirent[] = [];
|
|
65
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return 0; }
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
const path = join(dir, entry.name);
|
|
68
|
+
try {
|
|
69
|
+
if (entry.isSymbolicLink()) continue;
|
|
70
|
+
if (entry.isDirectory()) total += directoryBytes(path, depth + 1);
|
|
71
|
+
else if (entry.isFile()) total += lstatSync(path).size;
|
|
72
|
+
} catch { /* vanished or unreadable: not counted */ }
|
|
73
|
+
}
|
|
74
|
+
return total;
|
|
75
|
+
}
|
|
76
|
+
|
|
55
77
|
/** Every readable shard with its machine-scope directories; the walk behind the catalog and the capacity advisor. */
|
|
56
78
|
export function walkShards(base: string): { hash: string; key: string; local: boolean; machineDirs: string[] }[] {
|
|
57
79
|
const projectsDir = join(base, "projects");
|
|
@@ -75,6 +97,7 @@ export function scanCatalog(base: string): CatalogProject[] {
|
|
|
75
97
|
const captured = capturedSelection(base);
|
|
76
98
|
// The latest op per alias (a removal included): an alias lists only under the key it currently points at, never under a past target or after `alias remove`.
|
|
77
99
|
const latest = effectiveAliases(base);
|
|
100
|
+
const stateOf = lifecycleResolver(base);
|
|
78
101
|
const out: CatalogProject[] = [];
|
|
79
102
|
for (const shard of walkShards(base)) {
|
|
80
103
|
let rows = 0, bytes = 0, lastAt: string | null = null;
|
|
@@ -88,11 +111,17 @@ export function scanCatalog(base: string): CatalogProject[] {
|
|
|
88
111
|
const curated = curatedStats(base, shard.hash, shard.key, captured);
|
|
89
112
|
out.push({ hash: shard.hash, key: shard.key, local: shard.local,
|
|
90
113
|
aliases: [...latest.entries()].filter(([, key]) => key === shard.key).map(([alias]) => alias).sort(),
|
|
91
|
-
captureRows: rows, captureBytes: bytes, captureLastAt: lastAt, curatedEntries: curated.entries, curatedLastAt: curated.lastAt, curatedAvailable: curated.entries !== null, capMb: config.capMb, retentionDays: config.retentionDays
|
|
114
|
+
captureRows: rows, captureBytes: bytes, captureLastAt: lastAt, curatedEntries: curated.entries, curatedLastAt: curated.lastAt, curatedAvailable: curated.entries !== null, capMb: config.capMb, retentionDays: config.retentionDays,
|
|
115
|
+
state: stateOf(shard.key), diskBytes: directoryBytes(join(base, "projects", shard.hash)) });
|
|
92
116
|
}
|
|
93
117
|
return out.sort((a, b) => a.key.localeCompare(b.key));
|
|
94
118
|
}
|
|
95
119
|
|
|
120
|
+
/** A fresh scan of the shards for a read-only command (`amem usage`, `amem projects`): it writes nothing, `catalog.db` included. */
|
|
121
|
+
export function readCatalogFresh(base: string): CatalogProject[] {
|
|
122
|
+
return scanCatalog(base);
|
|
123
|
+
}
|
|
124
|
+
|
|
96
125
|
/** Rewrite `catalog.db` from the shards in one transaction: the same shards always reproduce the same rows. */
|
|
97
126
|
export function rebuildCatalog(opts: { home?: string } = {}): CatalogProject[] {
|
|
98
127
|
const base = memoryBase(opts.home);
|
|
@@ -105,12 +134,13 @@ export function rebuildCatalog(opts: { home?: string } = {}): CatalogProject[] {
|
|
|
105
134
|
db.transaction(() => {
|
|
106
135
|
const columns = db.query("PRAGMA table_info(projects)").all() as { name: string; notnull: number }[];
|
|
107
136
|
// This database is disposable. Rebuild older derived schemas instead of coercing unavailable into zero.
|
|
108
|
-
if (columns.length && (!columns.some(column => column.name === "curated_entries" && column.notnull === 0) || !columns.some(column => column.name === "curated_last_at")
|
|
137
|
+
if (columns.length && (!columns.some(column => column.name === "curated_entries" && column.notnull === 0) || !columns.some(column => column.name === "curated_last_at")
|
|
138
|
+
|| !columns.some(column => column.name === "state") || !columns.some(column => column.name === "disk_bytes"))) db.run("DROP TABLE projects");
|
|
109
139
|
db.run(`CREATE TABLE IF NOT EXISTS projects (hash TEXT PRIMARY KEY, key TEXT NOT NULL, local INTEGER NOT NULL, aliases TEXT NOT NULL,
|
|
110
|
-
capture_rows INTEGER NOT NULL, capture_bytes INTEGER NOT NULL, capture_last_at TEXT, curated_entries INTEGER, curated_last_at TEXT, cap_mb REAL NOT NULL, retention_days REAL NOT NULL)`);
|
|
140
|
+
capture_rows INTEGER NOT NULL, capture_bytes INTEGER NOT NULL, capture_last_at TEXT, curated_entries INTEGER, curated_last_at TEXT, cap_mb REAL NOT NULL, retention_days REAL NOT NULL, state TEXT NOT NULL DEFAULT 'active', disk_bytes INTEGER NOT NULL DEFAULT 0)`);
|
|
111
141
|
db.run("DELETE FROM projects");
|
|
112
|
-
for (const p of projects) db.query("INSERT INTO projects (hash, key, local, aliases, capture_rows, capture_bytes, capture_last_at, curated_entries, curated_last_at, cap_mb, retention_days) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
113
|
-
.run(p.hash, p.key, p.local ? 1 : 0, JSON.stringify(p.aliases), p.captureRows, p.captureBytes, p.captureLastAt, p.curatedEntries, p.curatedLastAt, p.capMb, p.retentionDays);
|
|
142
|
+
for (const p of projects) db.query("INSERT INTO projects (hash, key, local, aliases, capture_rows, capture_bytes, capture_last_at, curated_entries, curated_last_at, cap_mb, retention_days, state, disk_bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
143
|
+
.run(p.hash, p.key, p.local ? 1 : 0, JSON.stringify(p.aliases), p.captureRows, p.captureBytes, p.captureLastAt, p.curatedEntries, p.curatedLastAt, p.capMb, p.retentionDays, p.state, p.diskBytes);
|
|
114
144
|
}).immediate();
|
|
115
145
|
} finally { db.close(); }
|
|
116
146
|
return projects;
|
|
@@ -127,8 +157,11 @@ export function readCatalog(opts: { home?: string } = {}): CatalogProject[] {
|
|
|
127
157
|
db = new Database(file, { readonly: true });
|
|
128
158
|
db.run("PRAGMA busy_timeout = 3000");
|
|
129
159
|
const columns = new Set((db.query("PRAGMA table_info(projects)").all() as { name: string }[]).map(column => column.name));
|
|
130
|
-
if (!columns.has("curated_entries") || !columns.has("curated_last_at")) throw new Error("catalog schema needs rebuild");
|
|
160
|
+
if (!columns.has("curated_entries") || !columns.has("curated_last_at") || !columns.has("state") || !columns.has("disk_bytes")) throw new Error("catalog schema needs rebuild");
|
|
161
|
+
// The state is the person's decision in lifecycle.jsonl, which changes without touching a shard: read it live, never from the cached row.
|
|
162
|
+
const stateOf = lifecycleResolver(base);
|
|
131
163
|
return (db.query("SELECT * FROM projects ORDER BY key").all() as CatalogRow[]).map(fromRow)
|
|
164
|
+
.map(project => ({ ...project, state: stateOf(project.key) }))
|
|
132
165
|
.map(project => captured ? { ...project, curatedEntries: null, curatedLastAt: null, curatedAvailable: false } : project);
|
|
133
166
|
} catch { return rebuildCatalog(opts); }
|
|
134
167
|
finally { db?.close(); }
|
package/src/memory/commands.ts
CHANGED
|
@@ -2,32 +2,56 @@ import { extractNodes, GOAL_STATES, KINDS, MemoryStore, setMachineLabel, type Go
|
|
|
2
2
|
import { syncMemory, type SyncOptions } from "./sync.ts";
|
|
3
3
|
import { importClaudeMem } from "./import.ts";
|
|
4
4
|
import { memoryProjectKey, projectChain } from "./project.ts";
|
|
5
|
+
import { recallForViews, recallGraph, recallRows, recallShow } from "./recall.ts";
|
|
6
|
+
import type { GoalPromotionReport } from "./goal-promotion.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What the commands call themselves in their own messages. The default is amem's own surface; a host that exposes the same commands under its own
|
|
10
|
+
* name (agent-hub's `ahub memory` and `ahub goal`) passes it, so no host name is baked into the library.
|
|
11
|
+
*/
|
|
12
|
+
export interface CommandNames { memory: string; goal: string }
|
|
13
|
+
export const AMEM_NAMES: CommandNames = { memory: "amem", goal: "amem goal" };
|
|
5
14
|
|
|
6
15
|
export interface MemoryCommandOptions {
|
|
16
|
+
/** Host-authenticated person intent. Never accept this from model arguments. Required for retained history/as-of. */
|
|
17
|
+
person?: boolean;
|
|
18
|
+
/** Defaults to amem's own names. */
|
|
19
|
+
names?: CommandNames;
|
|
7
20
|
/** Startup-bound canonical project supplied by a host; no model-facing selector. */
|
|
8
21
|
project?: string;
|
|
9
22
|
store?: MemoryStore;
|
|
10
23
|
sync?: Omit<SyncOptions, "remote">;
|
|
11
24
|
open: () => MemoryStore;
|
|
12
25
|
readable: (notes: View[]) => View[];
|
|
26
|
+
stale?: (view: View) => boolean | undefined;
|
|
27
|
+
/** Prepared row ids only: the host must wait for successful output before recording rendered. */
|
|
28
|
+
outputReady?: (notes: readonly View[]) => void;
|
|
13
29
|
importPolicy: () => (text: string) => boolean;
|
|
14
30
|
/** Explicit person-status receipt; model-facing hosts omit this callback. */
|
|
15
31
|
onBridgeStatus?: (status: { skippedRows: number } | undefined) => void;
|
|
16
32
|
}
|
|
17
33
|
export interface GoalCommandOptions {
|
|
34
|
+
/** Defaults to amem's own names. */
|
|
35
|
+
names?: CommandNames;
|
|
18
36
|
project?: string;
|
|
19
37
|
store?: MemoryStore;
|
|
20
38
|
link?: (goal: string, task: string) => Promise<string>;
|
|
39
|
+
/** Optional host proposal hook; the goal state is saved before it runs. It never grants mutation authority. */
|
|
40
|
+
onDone?: (goal: GoalView, store: MemoryStore) => Promise<GoalPromotionReport>;
|
|
21
41
|
open: () => MemoryStore;
|
|
22
42
|
clean: (title: string, milestones: string[], project: string) => boolean;
|
|
23
43
|
}
|
|
24
44
|
|
|
25
45
|
/**
|
|
26
|
-
* #258:
|
|
46
|
+
* #258: the memory commands, straight on the machine's store (no host needed; agent-hub exposes the same as `ahub memory`). Reading is anyone's; promote, demote, forget,
|
|
27
47
|
* supersede and rebuild are a person's (the identity gate refuses agent shells).
|
|
28
48
|
*/
|
|
29
|
-
|
|
30
|
-
export const
|
|
49
|
+
/** The usage line of the memory commands under `name` (`amem` by default; agent-hub passes `ahub memory`). */
|
|
50
|
+
export const memoryUsage = (name: string = AMEM_NAMES.memory): string => `usage: ${name} [list] [--tier short|long] [--kind <kind>] [--peer <p>] [--machine <id>] [--since <iso>] [--limit N] | search <words> [--history] [--as-of <UTC>] | show <id> [--history] [--as-of <UTC>] | graph <id|node> [--hops 1|2] [--history] [--as-of <UTC>] | promote|demote|forget <id> | supersede <old> <new> | rebuild | sync [--remote <url>] | machine [--label <name>] | import claude-mem [--db <path>] [--limit N] [--restart] | dedupe [--apply] | suggestions | confirm|reject <suggestion id> [--json]`;
|
|
51
|
+
export const MEMORY_USAGE = memoryUsage();
|
|
52
|
+
/** The usage line of the goal commands under `name` (`amem goal` by default; agent-hub passes `ahub goal`). */
|
|
53
|
+
export const goalUsage = (name: string = AMEM_NAMES.goal): string => `usage: ${name} [list] [--all] | show <id> | new <title> [--milestone <text>]... [--depends <id>]... [--shared] | link <id> <task id> | state <id> open|active|blocked|done|dropped | done <id> | take <id> [--force] [--json]`;
|
|
54
|
+
export const GOAL_USAGE = goalUsage();
|
|
31
55
|
|
|
32
56
|
const fail = (message: string): never => { throw new Error(message); };
|
|
33
57
|
const one = (v: View) => `${v.id.slice(0, 8)} ${v.at.slice(0, 16).replace("T", " ")} ${v.tier.padEnd(5)} ${v.kind.padEnd(8)} ${v.peer.padEnd(8)} ${v.scope === "shared" ? "shared " : ""}${`${v.title ? `${v.title}: ` : ""}${v.text}`.replace(/\s+/g, " ").slice(0, 100)}${v.supersededBy ? ` (superseded by ${v.supersededBy.slice(0, 8)})` : ""}`;
|
|
@@ -41,64 +65,86 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
41
65
|
const rest = args.filter((a) => a !== "--json");
|
|
42
66
|
const flag = (name: string) => { const i = rest.indexOf(name); if (i < 0) return undefined; const v = rest[i + 1] ?? fail(`${name} needs a value`); rest.splice(i, 2); return v; };
|
|
43
67
|
const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
|
|
68
|
+
const names = opts.names ?? AMEM_NAMES, usage = memoryUsage(names.memory);
|
|
69
|
+
if (args.some(arg => arg === "--history" || arg === "--as-of" || arg.startsWith("--history=") || arg.startsWith("--as-of=")) && opts.person !== true) fail("historical recall requires authenticated person intent from the host");
|
|
44
70
|
const store = opts.store ?? opts.open();
|
|
45
71
|
const actor = "user";
|
|
46
72
|
const project = opts.project ?? memoryProjectKey(cwd);
|
|
47
|
-
const need = (v: View | undefined, id: string) => v ?? fail(`no memory entry ${id} (
|
|
73
|
+
const need = (v: View | undefined, id: string) => v ?? fail(`no memory entry ${id} (${names.memory} list shows the ids)`);
|
|
74
|
+
// #15 D4: this command is bound to one project. Crossing projects is the person's `amem search --all-projects`; here the flags would otherwise read as query words.
|
|
75
|
+
for (const crossing of ["--all-projects", "--include-archived"]) if (rest.includes(crossing)) fail(`${crossing} crosses projects: run amem search --all-projects; ${names.memory} reads this project only`);
|
|
76
|
+
// #15 T4: repo scope is the person's `amem promote --to repo`, which screens, previews the committed line and checks the repository's visibility. Here the flag would otherwise be ignored and the note promoted privately.
|
|
77
|
+
for (const repoOnly of ["--to", "--confirm", "--confirm-public"]) if (rest.includes(repoOnly)) fail(`${repoOnly} belongs to the person's amem promote <id> --to repo; ${names.memory} never writes a repository file`);
|
|
78
|
+
const stale = (view: View) => { try { return opts.stale?.(view) === true; } catch { return false; } };
|
|
79
|
+
const ready = (rows: readonly View[]) => { try { opts.outputReady?.(rows); } catch { /* optional output metadata cannot fail recall */ } };
|
|
80
|
+
const decorated = (view: View) => ({ ...view, ...(stale(view) ? { stale: true } : {}) });
|
|
81
|
+
if (!["list", "search", "show", "graph"].includes(sub) && (rest.includes("--history") || rest.includes("--as-of"))) fail("history/as-of apply only to memory reads");
|
|
48
82
|
try {
|
|
49
83
|
switch (sub) {
|
|
50
84
|
case "list": case "search": {
|
|
51
|
-
const tier = flag("--tier"), kind = flag("--kind"), peer = flag("--peer"), machine = flag("--machine"), since = flag("--since"), limit = flag("--limit");
|
|
85
|
+
const tier = flag("--tier"), kind = flag("--kind"), peer = flag("--peer"), machine = flag("--machine"), since = flag("--since"), limit = flag("--limit"), asOf = flag("--as-of");
|
|
86
|
+
const history = rest.includes("--history"); if (history) rest.splice(rest.indexOf("--history"), 1);
|
|
52
87
|
if (since !== undefined && Number.isNaN(Date.parse(since))) fail(`--since needs a date or time (ISO 8601), not ${since}`);
|
|
53
88
|
if (tier && tier !== "short" && tier !== "long") fail("--tier takes short or long");
|
|
54
89
|
if (kind && !KINDS.includes(kind as Kind)) fail(`--kind takes ${KINDS.join(", ")}`);
|
|
55
90
|
if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
|
|
56
|
-
const q = sub === "search" ? rest.join(" ")
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const all = store.list({ project, ...(tier ? { tier: tier as Tier } : {}), ...(kind ? { kind: kind as Kind } : {}), ...(peer ? { peer } : {}), ...(machine ? { machine } : {}), ...(since ? { since } : {}), ...(q ? { q } : {}), limit: Number.MAX_SAFE_INTEGER });
|
|
60
|
-
// Screened first, then cut to the limit: withheld entries never crowd out readable ones.
|
|
91
|
+
const q = sub === "search" ? rest.join(" ") : undefined;
|
|
92
|
+
const state = recallRows(store, project, { person: opts.person, history, asOf, query: q, limit: Number.MAX_SAFE_INTEGER });
|
|
93
|
+
const all = state.views.filter(view => (!tier || view.tier === tier) && (!kind || view.kind === kind) && (!peer || view.peer === peer) && (!machine || view.machine === machine) && (!since || Date.parse(view.at) >= Date.parse(since)));
|
|
61
94
|
const readable = opts.readable(all), rows = readable.slice(0, limit ? Number(limit) : 50);
|
|
95
|
+
ready(rows);
|
|
96
|
+
if (history || asOf !== undefined) {
|
|
97
|
+
const emitted = recallForViews(state, rows), publicIds = new Set(rows.map(view => view.id)), allForgotten = q?.trim() ? [] : state.forgotten;
|
|
98
|
+
const forgotten = allForgotten.slice(0, Math.max(0, (limit ? Math.floor(Number(limit)) : 50) - rows.length));
|
|
99
|
+
const result = { at: state.at, entries: rows.map(decorated), forgotten, relations: state.relations.filter(edge => publicIds.has(edge.from) && publicIds.has(edge.to)), incomplete: emitted.incomplete || readable.length > rows.length || forgotten.length < allForgotten.length, notices: emitted.notices };
|
|
100
|
+
return json ? JSON.stringify(result, null, 2) : [`retained memory as of ${state.at}`, ...result.notices, ...rows.map(view => `${stale(view) ? "(stale?) " : ""}${one(view)}${view.expiresAt && Date.parse(view.expiresAt) <= Date.parse(state.at) ? ` (expired at ${view.expiresAt})` : ""}`), ...forgotten.map(row => `forgotten ${row.id} at ${row.at} (no retained text shown)`), ...(result.incomplete ? ["(retained state is incomplete)"] : [])].join("\n");
|
|
101
|
+
}
|
|
62
102
|
if (json) return JSON.stringify(rows, null, 2);
|
|
63
|
-
|
|
64
|
-
const counted = opts.readable(store.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }));
|
|
65
|
-
const c = { machine: store.machine, long: counted.filter(v => v.tier === "long").length, short: counted.filter(v => v.tier === "short").length };
|
|
103
|
+
const counted = opts.readable(store.withDeferredPromotions(() => store.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true })));
|
|
66
104
|
const withheld = all.length - readable.length;
|
|
67
|
-
return [`
|
|
105
|
+
return [`memory for ${project} on machine ${store.machine.label}: ${counted.filter(view => view.tier === "long").length} long-term, ${counted.filter(view => view.tier === "short").length} short-term`, ...(rows.length ? rows.map(view => `${stale(view) ? "(stale?) " : ""}${one(view)}`) : ["(nothing)"]),
|
|
68
106
|
...(withheld ? [`(${withheld} withheld: they match a PII pattern; the dashboard shows them)`] : [])].join("\n");
|
|
69
107
|
}
|
|
70
108
|
case "show": {
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
if (!
|
|
74
|
-
|
|
109
|
+
const asOf = flag("--as-of"), history = rest.includes("--history"); if (history) rest.splice(rest.indexOf("--history"), 1);
|
|
110
|
+
const id = rest[0] ?? fail(`usage: ${names.memory} show <id>`), shown = recallShow(store, project, id, { person: opts.person, history, asOf });
|
|
111
|
+
if (!shown.ids.length) return fail(`no memory entry ${id} (${names.memory} list shows the ids)`);
|
|
112
|
+
if (shown.state) {
|
|
113
|
+
const rows = opts.readable(shown.state.views), ids = new Set(rows.map(view => view.id)); ready(rows);
|
|
114
|
+
const state = { ...recallForViews(shown.state, rows), views: rows.map(decorated), relations: shown.state.relations.filter(edge => ids.has(edge.from) && ids.has(edge.to)) };
|
|
115
|
+
return json ? JSON.stringify(state, null, 2) : [`retained memory as of ${state.at}`, ...state.notices, ...rows.flatMap(view => [...(stale(view) ? ["(stale?)"] : []), ...detail(view)]), ...state.forgotten.map(row => `forgotten ${row.id} at ${row.at} (no retained text shown)`), ...(state.incomplete ? ["(retained state is incomplete)"] : [])].join("\n");
|
|
116
|
+
}
|
|
117
|
+
const view = need(shown.entry, id); if (!opts.readable([view]).length) fail(`${view.id.slice(0, 8)} is withheld: it matches a PII pattern (the dashboard shows it)`);
|
|
118
|
+
ready([view]); return json ? JSON.stringify(view, null, 2) : [...(stale(view) ? ["(stale?)"] : []), ...detail(view)].join("\n");
|
|
75
119
|
}
|
|
76
120
|
case "graph": {
|
|
77
121
|
const hops = Number(flag("--hops") ?? 1);
|
|
78
122
|
if (hops !== 1 && hops !== 2) fail("--hops takes 1 or 2");
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
const g = store
|
|
123
|
+
const asOf = flag("--as-of"), history = rest.includes("--history"); if (history) rest.splice(rest.indexOf("--history"), 1);
|
|
124
|
+
const start = rest[0] ?? fail(`usage: ${names.memory} graph <id|node>`);
|
|
125
|
+
const g = recallGraph(store, project, start, hops, { person: opts.person, history, asOf });
|
|
126
|
+
if ((!start.includes(":") || start.startsWith("mem:")) && !g.nodes.length) fail(`no memory entry ${start} (${names.memory} list shows the ids)`);
|
|
127
|
+
const node = !start.includes(":") || start.startsWith("mem:") ? g.nodes[0]!.id : start;
|
|
82
128
|
return json ? JSON.stringify(g, null, 2) : [`${g.nodes.length} nodes, ${g.edges.length} edges around ${node}`, ...g.edges.map((e) => `${e.from} -${e.rel}-> ${e.to}`)].join("\n");
|
|
83
129
|
}
|
|
84
|
-
case "promote": { const v = need(store.get(rest[0] ?? fail(
|
|
85
|
-
case "demote": { const v = need(store.get(rest[0] ?? fail(
|
|
86
|
-
case "forget": { const v = need(store.forget(rest[0] ?? fail(
|
|
130
|
+
case "promote": { const v = need(store.get(rest[0] ?? fail(usage), project), rest[0]!); if (v.supersededBy) fail(`${v.id.slice(0, 8)} is replaced by ${v.supersededBy.slice(0, 8)}: promote that one instead`); const id = store.promote(v.id, actor, project) ?? fail(v.project !== project ? `${v.id.slice(0, 8)} is another project's note: promote it from that project` : `${v.id.slice(0, 8)} stays short-term: it holds a secret, matches a PII pattern or is about a private path`); return id === v.id ? `${v.id.slice(0, 8)} is already long-term` : `promoted to long-term as ${id.slice(0, 8)}`; }
|
|
131
|
+
case "demote": { const v = need(store.get(rest[0] ?? fail(usage), project), rest[0]!); if (v.tier !== "long") return `${v.id.slice(0, 8)} is already short-term`; store.demote(v.id, actor, project); return `demoted ${v.id.slice(0, 8)}: it expires ${store.get(v.id, project)?.expiresAt ?? "with its short-term lifetime"}`; }
|
|
132
|
+
case "forget": { const v = need(store.forget(rest[0] ?? fail(usage), actor, project), rest[0]!); return `forgot ${v.id.slice(0, 8)}${v.local ? "" : "; it stays in the sync remote's git history until a person rewrites it (docs/memory.md)"}`; }
|
|
87
133
|
case "supersede": {
|
|
88
|
-
if (rest.length !== 2) fail(
|
|
89
|
-
return store.supersede(rest[0]!, rest[1]!, actor, project) ? `${rest[0]} is superseded by ${rest[1]}` : fail(
|
|
134
|
+
if (rest.length !== 2) fail(`usage: ${names.memory} supersede <old> <new>`);
|
|
135
|
+
return store.supersede(rest[0]!, rest[1]!, actor, project) ? `${rest[0]} is superseded by ${rest[1]}` : fail(`both ids must name different entries, and the newer one must reach as far: shared for a shared entry, long-term for a long-term one (${names.memory} promote it first)`);
|
|
90
136
|
}
|
|
91
137
|
case "sync": {
|
|
92
138
|
const remote = flag("--remote");
|
|
93
|
-
if (rest.length) fail(
|
|
139
|
+
if (rest.length) fail(`usage: ${names.memory} sync [--remote <private git url>]`);
|
|
94
140
|
const r = await syncMemory(store, { ...opts.sync, ...(remote ? { remote } : {}) });
|
|
95
141
|
if (json) return JSON.stringify(r, null, 2);
|
|
96
|
-
if (!r.ok) fail(
|
|
142
|
+
if (!r.ok) fail(`${names.memory} sync: ${r.error}`);
|
|
97
143
|
return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries visible here${r.warning ? `\nnote: ${r.warning}` : ""}`;
|
|
98
144
|
}
|
|
99
145
|
case "dedupe": {
|
|
100
146
|
const apply = rest.includes("--apply");
|
|
101
|
-
if (rest.some(arg => arg !== "--apply")) fail(
|
|
147
|
+
if (rest.some(arg => arg !== "--apply")) fail(`usage: ${names.memory} dedupe [--apply] [--json]`);
|
|
102
148
|
const result = store.dedupe(project, apply);
|
|
103
149
|
if (json) return JSON.stringify(result, null, 2);
|
|
104
150
|
return [result.groups.length ? `${result.groups.length} duplicate import group(s)${apply ? `; appended ${result.applied} supersede operation(s)` : "; use --apply to reconcile"}` : "no duplicate import groups to reconcile",
|
|
@@ -108,7 +154,7 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
108
154
|
const db = flag("--db"), limit = flag("--limit");
|
|
109
155
|
const restart = rest.includes("--restart");
|
|
110
156
|
if (restart) rest.splice(rest.indexOf("--restart"), 1);
|
|
111
|
-
if (rest[0] !== "claude-mem" || rest.length !== 1) fail(
|
|
157
|
+
if (rest[0] !== "claude-mem" || rest.length !== 1) fail(`usage: ${names.memory} import claude-mem [--db <path>] [--limit N] [--restart]`);
|
|
112
158
|
if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
|
|
113
159
|
const isPii = opts.importPolicy();
|
|
114
160
|
const key = opts.project ?? memoryProjectKey(cwd);
|
|
@@ -120,10 +166,10 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
120
166
|
case "suggestions": {
|
|
121
167
|
const pending = store.suggestions(project).filter((x) => x.status === "suggested");
|
|
122
168
|
if (json) return JSON.stringify(pending, null, 2);
|
|
123
|
-
return pending.length ? pending.map((x) => `${x.id.slice(0, 8)} ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)} (suggested by
|
|
169
|
+
return pending.length ? pending.map((x) => `${x.id.slice(0, 8)} ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)} (suggested by a model ${x.at}; not evidence until confirmed)`).join("\n") : "no edge suggestions waiting";
|
|
124
170
|
}
|
|
125
171
|
case "confirm": case "reject": {
|
|
126
|
-
const x = store.judge(rest[0] ?? fail(`usage:
|
|
172
|
+
const x = store.judge(rest[0] ?? fail(`usage: ${names.memory} ${sub} <suggestion id>`), sub, actor, project) ?? fail(`no suggestion ${rest[0]} (${names.memory} suggestions lists them)`);
|
|
127
173
|
return `${sub === "confirm" ? "confirmed" : "rejected"}: ${x.from.slice(0, 8)} ${x.rel} ${x.to.slice(0, 8)}`;
|
|
128
174
|
}
|
|
129
175
|
case "machine": {
|
|
@@ -132,7 +178,7 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
132
178
|
return json ? JSON.stringify(m) : `this machine: ${m.id} (${m.label})`;
|
|
133
179
|
}
|
|
134
180
|
case "rebuild": { store.rebuild(); const c = store.counts(); return `rebuilt the index from ${c.segments} segment${c.segments === 1 ? "" : "s"}: ${c.long} long-term, ${c.short} short-term entries`; }
|
|
135
|
-
default: return fail(
|
|
181
|
+
default: return fail(usage);
|
|
136
182
|
}
|
|
137
183
|
} finally {
|
|
138
184
|
try { opts.onBridgeStatus?.(store.bridgeStatus()); }
|
|
@@ -140,15 +186,15 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
140
186
|
}
|
|
141
187
|
}
|
|
142
188
|
|
|
143
|
-
const goalOne = (g: GoalView, me: string) => `${g.id.slice(0, 8)} ${g.state.padEnd(7)} ${g.title}${g.scope === "shared" ? " [shared]" : ""} ${leaseText(g, me)}`;
|
|
144
|
-
function leaseText(g: GoalView, me: string): string {
|
|
189
|
+
const goalOne = (g: GoalView, me: string, goal: string = AMEM_NAMES.goal) => `${g.id.slice(0, 8)} ${g.state.padEnd(7)} ${g.title}${g.scope === "shared" ? " [shared]" : ""} ${leaseText(g, me, goal)}`;
|
|
190
|
+
function leaseText(g: GoalView, me: string, goal: string): string {
|
|
145
191
|
const who = (m: string) => m === me ? "this machine" : `machine ${m.slice(0, 8)}`;
|
|
146
|
-
if (g.lease.holder) return `held by ${who(g.lease.holder)} until ${g.lease.until}${g.lease.conflict.length ? `, CONFLICT with ${g.lease.conflict.map(who).join(", ")} (the earlier claim holds; a person settles it with
|
|
192
|
+
if (g.lease.holder) return `held by ${who(g.lease.holder)} until ${g.lease.until}${g.lease.conflict.length ? `, CONFLICT with ${g.lease.conflict.map(who).join(", ")} (the earlier claim holds; a person settles it with ${goal} take --force)` : ""}`;
|
|
147
193
|
return g.lease.expired ? `free (${who(g.lease.lastHolder!)}'s lease ended ${g.lease.until})` : "free";
|
|
148
194
|
}
|
|
149
195
|
|
|
150
196
|
/**
|
|
151
|
-
* #258: `ahub goal
|
|
197
|
+
* #258: the goal commands (agent-hub exposes the same as `ahub goal`), long-term tasks. Listing and showing are anyone's; new, link, state, done and take are a
|
|
152
198
|
* person's. A peer links its own board task with hub_task_propose's `goal`.
|
|
153
199
|
*/
|
|
154
200
|
export async function rawGoalCommand(args: string[], cwd: string, opts: GoalCommandOptions): Promise<string> {
|
|
@@ -156,11 +202,12 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
|
|
|
156
202
|
const rest = args.filter((a) => a !== "--json");
|
|
157
203
|
const take = (name: string) => { const out: string[] = []; for (let i = rest.indexOf(name); i >= 0; i = rest.indexOf(name)) { out.push(rest[i + 1] ?? fail(`${name} needs a value`)); rest.splice(i, 2); } return out; };
|
|
158
204
|
const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
|
|
205
|
+
const names = opts.names ?? AMEM_NAMES, usage = goalUsage(names.goal);
|
|
159
206
|
const store = opts.store ?? opts.open();
|
|
160
207
|
const project = opts.project ?? memoryProjectKey(cwd);
|
|
161
208
|
const me = store.machine.id;
|
|
162
209
|
// Only this project's long-term tasks and shared ones, like every other lookup an agent may run.
|
|
163
|
-
const need = (id: string | undefined) => store.goalOf(id?.replace(/^goal:/, "") ?? fail(
|
|
210
|
+
const need = (id: string | undefined) => store.goalOf(id?.replace(/^goal:/, "") ?? fail(usage), project) ?? fail(`no long-term task ${id} for this project (${names.goal} list shows them)`);
|
|
164
211
|
const clean = (title: string, milestones: string[], of = project) => opts.clean(title, milestones, of);
|
|
165
212
|
try {
|
|
166
213
|
switch (sub) {
|
|
@@ -169,36 +216,43 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
|
|
|
169
216
|
const listed = store.goals(project).filter((g) => all || (g.state !== "done" && g.state !== "dropped"));
|
|
170
217
|
const goals = listed.filter((g) => clean(g.title, g.milestones, g.project)), withheld = listed.length - goals.length;
|
|
171
218
|
if (json) return JSON.stringify(goals, null, 2);
|
|
172
|
-
return [...(goals.length ? goals.map((g) => goalOne(g, me)) : [`no ${all ? "" : "open "}long-term tasks for ${project} (
|
|
219
|
+
return [...(goals.length ? goals.map((g) => goalOne(g, me, names.goal)) : [`no ${all ? "" : "open "}long-term tasks for ${project} (${names.goal} new <title>)`]),
|
|
173
220
|
...(withheld ? [`(${withheld} withheld: they match a PII pattern or a private path)`] : [])].join("\n");
|
|
174
221
|
}
|
|
175
222
|
case "show": {
|
|
176
223
|
const g = need(rest[0]);
|
|
177
224
|
if (!clean(g.title, g.milestones, g.project)) fail(`${g.id.slice(0, 8)} is withheld: it matches a PII pattern or a private path`);
|
|
178
225
|
if (json) return JSON.stringify(g, null, 2);
|
|
179
|
-
return [goalOne(g, me), `${g.id}, project ${g.project}, created ${g.at} on machine ${g.machine.slice(0, 8)}`,
|
|
226
|
+
return [goalOne(g, me, names.goal), `${g.id}, project ${g.project}, created ${g.at} on machine ${g.machine.slice(0, 8)}`,
|
|
180
227
|
...g.milestones.map((m) => `milestone: ${m}`), ...g.dependsOn.map((d) => `depends on ${d.slice(0, 8)}`),
|
|
181
228
|
...(g.links.length ? g.links.map((l) => `board task ${l.task} (linked ${l.at} on machine ${l.machine.slice(0, 8)})`) : ["no board tasks linked yet"])].join("\n");
|
|
182
229
|
}
|
|
183
230
|
case "new": {
|
|
184
231
|
const milestones = take("--milestone"), depends = take("--depends").map((d) => need(d).id);
|
|
185
232
|
const shared = rest.includes("--shared");
|
|
186
|
-
const title = rest.filter((a) => a !== "--shared").join(" ").trim() || fail(
|
|
233
|
+
const title = rest.filter((a) => a !== "--shared").join(" ").trim() || fail(usage);
|
|
187
234
|
if (!clean(title, milestones)) fail("a long-term task syncs to every machine: this one holds a secret, a PII pattern or a private path and is not saved");
|
|
188
235
|
const id = store.goal({ project, scope: shared ? "shared" : "project", title, milestones, dependsOn: depends, actor: "user" });
|
|
189
236
|
return `long-term task ${id.slice(0, 8)} created: ${title}`;
|
|
190
237
|
}
|
|
191
238
|
case "link": {
|
|
192
239
|
const g = need(rest[0]);
|
|
193
|
-
const task = rest[1] ?? fail(
|
|
240
|
+
const task = rest[1] ?? fail(`usage: ${names.goal} link <id> <task id>`);
|
|
194
241
|
if (!/^\d+$/.test(task)) fail("the task is a board task id of this project");
|
|
195
|
-
return await (opts.link ?? fail("linking needs
|
|
242
|
+
return await (opts.link ?? fail("linking a goal to a task needs a host that has a task board; amem alone has none"))(g.id, task);
|
|
196
243
|
}
|
|
197
244
|
case "state": case "done": {
|
|
198
245
|
const g = need(rest[0]);
|
|
199
246
|
const state = (sub === "done" ? "done" : rest[1]) as GoalState;
|
|
200
247
|
if (!GOAL_STATES.includes(state)) fail(`the state is one of ${GOAL_STATES.join(", ")}`);
|
|
201
248
|
store.goalState(g.id, state, "user");
|
|
249
|
+
if (state === "done" && opts.onDone) {
|
|
250
|
+
const current = store.goalOf(g.id, project);
|
|
251
|
+
if (!current || current.id !== g.id || current.state !== "done") return fail("goal state changed before promotion offering");
|
|
252
|
+
const promotion = await opts.onDone(current, store);
|
|
253
|
+
if (json) return JSON.stringify({ goalId: current.id, state: current.state, promotion }, null, 2);
|
|
254
|
+
return `long-term task ${current.id.slice(0, 8)} is done\npromotion candidates: ${promotion.offered} offered, ${promotion.alreadyOffered} already offered, ${promotion.withheld} withheld${promotion.truncated ? "; more linked notes remain (truncated)" : ""}`;
|
|
255
|
+
}
|
|
202
256
|
return `long-term task ${g.id.slice(0, 8)} is ${state}`;
|
|
203
257
|
}
|
|
204
258
|
case "take": {
|
|
@@ -207,7 +261,7 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
|
|
|
207
261
|
if (!r.ok) fail(`held by machine ${r.holder.slice(0, 8)} until ${r.until}; wait for it to expire or take it over with --force`);
|
|
208
262
|
return `this machine holds long-term task ${g.id.slice(0, 8)} until ${r.until}${rest.includes("--force") ? " (earlier claims are void)" : ""}`;
|
|
209
263
|
}
|
|
210
|
-
default: return fail(
|
|
264
|
+
default: return fail(usage);
|
|
211
265
|
}
|
|
212
266
|
} finally { if (!opts.store) store.close(); }
|
|
213
267
|
}
|