@staix/agent-mem 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/bin/amem.js +9 -0
- package/package.json +32 -4
- package/src/cli.ts +58 -0
- package/src/commands-table.ts +123 -0
- package/src/completion.ts +240 -0
- package/src/index.ts +7 -0
- package/src/memory/commands.ts +203 -0
- package/src/memory/home.ts +10 -0
- package/src/memory/import.ts +90 -0
- package/src/memory/platform.ts +131 -0
- package/src/memory/project.ts +72 -0
- package/src/memory/store.ts +805 -0
- package/src/memory/sync.ts +144 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 STAIxBWLB
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,147 @@
|
|
|
1
|
-
#
|
|
1
|
+
# agent-mem
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Curated memory with append-only JSONL logs, a derived SQLite index, short- and long-term tiers, a typed graph, goals with leases, and git sync between machines. Extracted from [agent-hub](https://github.com/STAIxBWLB/agent-hub), with compatible storage and entry IDs.
|
|
4
|
+
|
|
5
|
+
Version: 0.1.1. Requires Bun 1.4.2 or later; no runtime dependencies.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Install the public package:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
bun add -g @staix/agent-mem
|
|
13
|
+
amem --version
|
|
14
|
+
amem --help
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
From a checkout, install development dependencies with `bun install` and run `bun src/cli.ts --help`.
|
|
18
|
+
|
|
19
|
+
## CLI
|
|
20
|
+
|
|
21
|
+
The flat `amem` commands mirror `ahub memory` arguments; goals use `amem goal`:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
amem list --json
|
|
25
|
+
amem search "release decision" --limit 10
|
|
26
|
+
amem show <id> --json
|
|
27
|
+
amem graph <id-or-node> --hops 2 --json
|
|
28
|
+
amem suggestions
|
|
29
|
+
amem goal list --all --json
|
|
30
|
+
amem goal new "Ship a release" --milestone "Review changes"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`amem goal link` requires the host task boundary; use `ahub goal link` for board links.
|
|
34
|
+
|
|
35
|
+
Memory commands: `list`, `search`, `show`, `graph`, `suggestions`, `confirm`, `reject`, `promote`, `demote`, `forget`, `supersede`, `rebuild`, `dedupe`, `sync`, `machine`, and `import`. Use `--json` where supported by the corresponding `ahub memory` command. `dedupe` previews candidates; `dedupe --apply` appends reconciliation operations.
|
|
36
|
+
|
|
37
|
+
`amem` is for the person. Every command is refused when an agent-session marker is present, including `AGENTHUB_PEER_ID`, `CLAUDECODE`, and `CODEX_THREAD_ID`. Agents read memory through the hub's `hub_memory_*` tools. Environment-marker detection is an honest-session guard, not authentication. Mutating commands also require a TTY on stdin.
|
|
38
|
+
|
|
39
|
+
This package does not ship the host's privacy policy. Person reads show stored data without screening. Import and promotion retain the built-in secret screen. Commands that cross the privacy boundary require the person's explicit `--policy-free` choice:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
amem promote <id> --policy-free
|
|
43
|
+
amem import claude-mem --db <path> --policy-free
|
|
44
|
+
amem sync --remote <private-git-url> --policy-free
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Use `ahub memory` for the policy-applying path. `--policy-free` does not bypass the secret screen or the sync remote's privacy checks. Forgetting a shared note appends an operation; its previous content remains in the remote's git history.
|
|
48
|
+
|
|
49
|
+
## Shell completion
|
|
50
|
+
|
|
51
|
+
Generate completion for `zsh`, `bash`, or `fish` with `amem completion <shell>`. Generation prints a script to stdout; it does not install files or edit shell configuration. The following commands are for a person's terminal.
|
|
52
|
+
|
|
53
|
+
### zsh
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
mkdir -p ~/.zfunc
|
|
57
|
+
amem completion zsh > ~/.zfunc/_amem
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Enable it by adding these lines to `~/.zshrc`, placing `fpath` before your existing `compinit` call (use only one `compinit` call):
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
fpath=(~/.zfunc $fpath)
|
|
64
|
+
autoload -Uz compinit
|
|
65
|
+
compinit
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Start a new shell. After upgrading amem, regenerate `~/.zfunc/_amem` with the same command and start a new shell. To remove it, delete `~/.zfunc/_amem` and start a new shell; remove the added `fpath` entry only if no other completions use that directory.
|
|
69
|
+
|
|
70
|
+
### bash
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
mkdir -p ~/.local/share/bash-completion/completions
|
|
74
|
+
amem completion bash > ~/.local/share/bash-completion/completions/amem
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Enable it by adding this line to `~/.bashrc`:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
source ~/.local/share/bash-completion/completions/amem
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Source that file or start a new interactive Bash. After upgrading amem, regenerate it and source it again. To remove it, delete that file, remove its source line from `~/.bashrc`, and run `complete -r amem` in the current shell (or start a new shell).
|
|
84
|
+
|
|
85
|
+
### fish
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
mkdir -p ~/.config/fish/completions
|
|
89
|
+
amem completion fish > ~/.config/fish/completions/amem.fish
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Fish loads this directory automatically. Start a new fish shell. After upgrading amem, regenerate the file and start a new shell. To remove it, delete `~/.config/fish/completions/amem.fish` and start a new shell, or run `complete -c amem -e` to clear the current shell's definitions.
|
|
93
|
+
|
|
94
|
+
### Manual Tab checks
|
|
95
|
+
|
|
96
|
+
These interactive checks remain unrun until a person checks them in each installed shell; automated syntax/candidate tests do not establish terminal behavior.
|
|
97
|
+
|
|
98
|
+
- Type `amem ` and press Tab: top-level commands appear.
|
|
99
|
+
- Type `amem goal ` and press Tab: goal subcommands appear.
|
|
100
|
+
- Type `amem list --tier ` and press Tab: `short` and `long` appear.
|
|
101
|
+
- Type `amem goal state <id> ` and press Tab: goal states appear.
|
|
102
|
+
- Type `amem import claude-mem --db ` and press Tab in a directory containing a filename with spaces: the path remains one argument.
|
|
103
|
+
- Type `amem completion ` and press Tab: `zsh`, `bash`, and `fish` appear.
|
|
104
|
+
|
|
105
|
+
## Data directory
|
|
106
|
+
|
|
107
|
+
`memoryHome()` selects `AMEM_HOME` first, otherwise `$AGENTHUB_HOME/memory`, otherwise `~/.agenthub/memory`. `AMEM_HOME` names the memory directory itself; `AGENTHUB_HOME` names its parent. Existing agent-hub memory directories need no migration. An explicit `StoreOptions.home` retains the parent-directory convention and appends `/memory`.
|
|
108
|
+
|
|
109
|
+
The JSONL segments are authoritative. `index.db` is derived and can be rebuilt. Entries are immutable; changes append operations. The compatibility schema remains `agent-hub.memory/v1` because it participates in entry IDs.
|
|
110
|
+
|
|
111
|
+
## Library
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { MemoryStore, memoryProjectKey } from "@staix/agent-mem";
|
|
115
|
+
|
|
116
|
+
const store = new MemoryStore();
|
|
117
|
+
try {
|
|
118
|
+
const notes = store.list({ project: memoryProjectKey(process.cwd()) });
|
|
119
|
+
console.log(notes);
|
|
120
|
+
} finally {
|
|
121
|
+
store.close();
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The root entry needs Bun (it loads `bun:sqlite`). Node hosts that need only the deny list, hardened git invocation, `realPath` or process identity import `@staix/agent-mem/platform`, which uses `node:` built-ins only.
|
|
126
|
+
|
|
127
|
+
The entry point exports the store, types and identity helpers, sync and claude-mem import, project/home helpers, raw memory/goal command handlers, and platform helpers. Hosts inject their own privacy policy through `StoreOptions.screen`; CLI wrappers can supply screened reads and import policy through the raw command options.
|
|
128
|
+
|
|
129
|
+
## Development
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
bun install --frozen-lockfile
|
|
133
|
+
bun x tsc --noEmit
|
|
134
|
+
bun scripts/check-boundary.ts
|
|
135
|
+
bun test
|
|
136
|
+
npm pack --dry-run
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
CI runs the typecheck, boundary check and tests on Ubuntu and macOS with Bun 1.4.2. Tests use synthetic fixtures and isolated homes, not personal memory stores.
|
|
140
|
+
|
|
141
|
+
## Publishing
|
|
142
|
+
|
|
143
|
+
0.1.0 was published once by hand, because npm trusted publishing requires an existing package; the trusted publisher for repository `STAIxBWLB/agent-mem` and workflow `release.yml` was configured after it. Every later release goes through the tag workflow.
|
|
144
|
+
|
|
145
|
+
After setup, a `v*` tag triggers both operating-system gates. The tag must equal `v` plus `package.json`'s version. Only after both gates pass does the release job install npm 11.20.0 (trusted publishing needs 11.5.1 or later) and run `npm publish --access public`, authenticated through GitHub OIDC with `id-token: write`; npm adds provenance itself. The workflow uses no `NPM_TOKEN`.
|
|
146
|
+
|
|
147
|
+
License: MIT.
|
package/bin/amem.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Plain JavaScript: refuse Node before it can load TypeScript or bun:sqlite.
|
|
3
|
+
const version = /^(\d+)\.(\d+)\.(\d+)/.exec(process.versions.bun ?? "");
|
|
4
|
+
if (!version || Number(version[1]) < 1 || (Number(version[1]) === 1 && (Number(version[2]) < 4 || (Number(version[2]) === 4 && Number(version[3]) < 2)))) {
|
|
5
|
+
console.error("amem: Bun >=1.4.2 is required. Install Bun: https://bun.sh");
|
|
6
|
+
process.exit(1);
|
|
7
|
+
}
|
|
8
|
+
const { main } = await import("../src/cli.ts");
|
|
9
|
+
await main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@staix/agent-mem",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Curated, shared memory for coding agents: append-only logs, a derived index, tiers and a typed graph.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/STAIxBWLB/agent-mem.git"
|
|
10
|
+
},
|
|
11
|
+
"engines": {
|
|
12
|
+
"bun": ">=1.4.2"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/bun": "^1.3.0",
|
|
16
|
+
"typescript": "^5.8.0"
|
|
17
|
+
},
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.ts",
|
|
20
|
+
"./platform": "./src/memory/platform.ts"
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"amem": "./bin/amem.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src/",
|
|
27
|
+
"bin/",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"check": "bun x tsc --noEmit && bun scripts/check-boundary.ts && bun test --timeout 20000"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { MemoryStore, looksSecret } from "./memory/store.ts";
|
|
3
|
+
import { rawMemoryCommand, rawGoalCommand } from "./memory/commands.ts";
|
|
4
|
+
|
|
5
|
+
import { commandHelp, parseCommand } from "./commands-table.ts";
|
|
6
|
+
import { completionScript } from "./completion.ts";
|
|
7
|
+
|
|
8
|
+
export const PERSON_ONLY = "amem is for the person; agents read memory through the hub's hub_memory_* tools";
|
|
9
|
+
const VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
|
|
10
|
+
export interface CliContext { cwd?: string; env?: Readonly<Record<string, string | undefined>>; stdinTTY?: boolean }
|
|
11
|
+
|
|
12
|
+
/** Honest native-shell defaults, matching every marker detectCliIdentity treats as non-console (invalid included). */
|
|
13
|
+
export function agentMarked(env: Readonly<Record<string, string | undefined>>): boolean {
|
|
14
|
+
return ["AGENTHUB_PEER_ID", "CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID"].some(key => env[key] !== undefined);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function runCli(argv: readonly string[], context: CliContext = {}): Promise<string> {
|
|
18
|
+
if (agentMarked(context.env ?? process.env)) throw new Error(PERSON_ONLY);
|
|
19
|
+
if (argv[0] === "--version" || argv[0] === "version") return `amem ${VERSION}`;
|
|
20
|
+
if (argv[0] === "--help" || argv[0] === "help" || argv.includes("--help")) return commandHelp();
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = parseCommand(argv);
|
|
24
|
+
if (parsed.path[0] === "version") return `amem ${VERSION}`;
|
|
25
|
+
if (parsed.path[0] === "completion") {
|
|
26
|
+
if (parsed.positionals.length !== 1) throw new Error("usage: amem completion zsh|bash|fish");
|
|
27
|
+
return completionScript(parsed.positionals[0]!);
|
|
28
|
+
}
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (argv.find(arg => !arg.startsWith("--")) === "completion") Object.assign(error as Error, { exitCode: 2 });
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
const { definition, options, rawArgs: args } = parsed;
|
|
34
|
+
const goal = parsed.path[0] === "goal", sub = goal ? parsed.path[1]! : parsed.path[0]!;
|
|
35
|
+
const policyFree = options["--policy-free"] === true;
|
|
36
|
+
const mutating = definition.mutates === true || definition.mutates === "apply" && options["--apply"] === true || definition.mutates === "label" && options["--label"] !== undefined;
|
|
37
|
+
if (mutating && !(context.stdinTTY ?? process.stdin.isTTY)) throw new Error("mutating commands require a terminal on stdin");
|
|
38
|
+
if (definition.policyFree && !policyFree) {
|
|
39
|
+
throw new Error(`${sub} requires --policy-free; use ahub memory ${sub} to apply the host's privacy policy`);
|
|
40
|
+
}
|
|
41
|
+
// ponytail: board task links need the host's authenticated task boundary; add a host bridge before supporting them here.
|
|
42
|
+
if (goal && sub === "link") throw new Error("goal link needs host integration; use ahub goal link");
|
|
43
|
+
const crosses = definition.policyFree === true && policyFree;
|
|
44
|
+
// Reads can trigger held-note reconciliation. Defer it unless this terminal explicitly authorized crossing policy.
|
|
45
|
+
const open = () => new MemoryStore({ screen: note => !crosses ? "skip" : looksSecret(`${note.title ?? ""}\n${note.text}`) ? "keep" : "promote" });
|
|
46
|
+
const cwd = context.cwd ?? process.cwd();
|
|
47
|
+
// A person's reads are unscreened. Writes crossing the boundary remain secret-screened without host policy.
|
|
48
|
+
return goal
|
|
49
|
+
? rawGoalCommand(args, cwd, { open, clean: (title, milestones) => !mutating || !looksSecret(`${title}\n${milestones.join("\n")}`) })
|
|
50
|
+
: rawMemoryCommand(args, cwd, { open, readable: notes => notes, importPolicy: () => () => false });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function main(argv = process.argv.slice(2)): Promise<void> {
|
|
54
|
+
try { console.log(await runCli(argv)); }
|
|
55
|
+
catch (error) { console.error(`amem: ${(error as Error).message}`); process.exitCode = (error as { exitCode?: number }).exitCode ?? 1; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (import.meta.main) await main();
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { GOAL_STATES, KINDS } from "./memory/store.ts";
|
|
2
|
+
|
|
3
|
+
export interface OptionDefinition { description: string; value?: "text" | "path" | readonly string[] }
|
|
4
|
+
export interface ArgumentDefinition { name: string; values?: readonly string[]; path?: boolean; variadic?: boolean }
|
|
5
|
+
export interface CommandDefinition {
|
|
6
|
+
description: string;
|
|
7
|
+
options?: Record<string, OptionDefinition>;
|
|
8
|
+
args?: readonly ArgumentDefinition[];
|
|
9
|
+
commands?: Record<string, CommandDefinition>;
|
|
10
|
+
mutates?: boolean | "apply" | "label";
|
|
11
|
+
policyFree?: boolean;
|
|
12
|
+
defaultCommand?: string;
|
|
13
|
+
}
|
|
14
|
+
const flag = (description: string): OptionDefinition => ({ description });
|
|
15
|
+
const text = (description: string): OptionDefinition => ({ description, value: "text" });
|
|
16
|
+
const json = { "--json": flag("Print JSON where supported") };
|
|
17
|
+
const policy = { "--policy-free": flag("Explicitly bypass host privacy policy") };
|
|
18
|
+
const filters = { ...json, "--tier": { description: "Memory tier", value: ["short", "long"] } as OptionDefinition,
|
|
19
|
+
"--kind": { description: "Note kind", value: KINDS }, "--peer": text("Author peer"), "--machine": text("Machine ID"),
|
|
20
|
+
"--since": text("ISO date or time"), "--limit": text("Maximum results") };
|
|
21
|
+
export const GLOBAL_OPTIONS = { "--help": flag("Show help") };
|
|
22
|
+
export const ROOT_OPTIONS = { "--version": flag("Show version"), ...json };
|
|
23
|
+
export const COMMANDS: Record<string, CommandDefinition> = {
|
|
24
|
+
list: { description: "List memory", options: filters },
|
|
25
|
+
search: { description: "Search memory", options: filters, args: [{ name: "words", variadic: true }] },
|
|
26
|
+
show: { description: "Show a note", options: json, args: [{ name: "id" }] },
|
|
27
|
+
graph: { description: "Show a graph", options: { ...json, "--hops": { description: "Graph depth", value: ["1", "2"] } }, args: [{ name: "id-or-node" }] },
|
|
28
|
+
suggestions: { description: "List pending suggestions", options: json },
|
|
29
|
+
confirm: { description: "Confirm a suggestion", options: json, args: [{ name: "id" }], mutates: true },
|
|
30
|
+
reject: { description: "Reject a suggestion", options: json, args: [{ name: "id" }], mutates: true },
|
|
31
|
+
promote: { description: "Promote a note", options: { ...json, ...policy }, args: [{ name: "id" }], mutates: true, policyFree: true },
|
|
32
|
+
demote: { description: "Demote a note", options: json, args: [{ name: "id" }], mutates: true },
|
|
33
|
+
forget: { description: "Forget a note", options: json, args: [{ name: "id" }], mutates: true },
|
|
34
|
+
supersede: { description: "Replace a note", options: json, args: [{ name: "old-id" }, { name: "new-id" }], mutates: true },
|
|
35
|
+
rebuild: { description: "Rebuild the derived index", options: json, mutates: true },
|
|
36
|
+
dedupe: { description: "Preview duplicate reconciliation", options: { ...json, "--apply": flag("Append reconciliation operations") }, mutates: "apply" },
|
|
37
|
+
sync: { description: "Sync a private remote", options: { ...json, ...policy, "--remote": text("Private git URL") }, mutates: true, policyFree: true },
|
|
38
|
+
machine: { description: "Show or label this machine", options: { ...json, "--label": text("Machine label") }, mutates: "label" },
|
|
39
|
+
import: { description: "Import external memory", options: { ...json, ...policy, "--db": { description: "SQLite database file", value: "path" }, "--limit": text("Maximum observations"), "--restart": flag("Restart import cursor") }, mutates: true, policyFree: true,
|
|
40
|
+
commands: { "claude-mem": { description: "Import claude-mem observations" } } },
|
|
41
|
+
goal: { description: "Long-term goals", options: json, defaultCommand: "list", commands: {
|
|
42
|
+
list: { description: "List goals", options: { "--all": flag("Include completed goals") } },
|
|
43
|
+
show: { description: "Show a goal", args: [{ name: "id" }] },
|
|
44
|
+
new: { description: "Create a goal", options: { "--milestone": text("Milestone"), "--depends": text("Dependency goal ID"), "--shared": flag("Share across projects") }, args: [{ name: "title", variadic: true }], mutates: true },
|
|
45
|
+
link: { description: "Link through ahub goal link", args: [{ name: "id" }, { name: "task-id" }], mutates: true },
|
|
46
|
+
state: { description: "Set goal state", args: [{ name: "id" }, { name: "state", values: GOAL_STATES }], mutates: true },
|
|
47
|
+
done: { description: "Complete a goal", args: [{ name: "id" }], mutates: true },
|
|
48
|
+
take: { description: "Take a goal lease", options: { "--force": flag("Override the prior lease") }, args: [{ name: "id" }], mutates: true },
|
|
49
|
+
} },
|
|
50
|
+
completion: { description: "Print static shell completion", args: [{ name: "shell", values: ["zsh", "bash", "fish"] }] },
|
|
51
|
+
help: { description: "Show help" }, version: { description: "Show version" },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export interface CompletionDefinition { path: string[]; options: Record<string, OptionDefinition>; args: ArgumentDefinition[]; children: string[] }
|
|
55
|
+
export function completionDefinitions(): CompletionDefinition[] {
|
|
56
|
+
const rows: CompletionDefinition[] = [{ path: [], options: { ...GLOBAL_OPTIONS, ...ROOT_OPTIONS, ...COMMANDS.list!.options }, args: [], children: Object.keys(COMMANDS) }];
|
|
57
|
+
const walk = (commands: Record<string, CommandDefinition>, path: string[], inherited: Record<string, OptionDefinition>) => {
|
|
58
|
+
for (const [name, command] of Object.entries(commands)) {
|
|
59
|
+
const next = [...path, name], ownOptions = { ...inherited, ...command.options };
|
|
60
|
+
const options = { ...ownOptions, ...(command.defaultCommand ? command.commands?.[command.defaultCommand]?.options : {}) };
|
|
61
|
+
rows.push({ path: next, options, args: [...command.args ?? []], children: Object.keys(command.commands ?? {}) });
|
|
62
|
+
if (command.commands) walk(command.commands, next, ownOptions);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
walk(COMMANDS, [], GLOBAL_OPTIONS); return rows;
|
|
66
|
+
}
|
|
67
|
+
export interface ParsedCommand { path: string[]; definition: CommandDefinition; options: Record<string, string | boolean>; positionals: string[]; rawArgs: string[] }
|
|
68
|
+
|
|
69
|
+
/** Pure validation, shared definitions with help/completion; no store, files or network. */
|
|
70
|
+
export function parseCommand(argv: readonly string[]): ParsedCommand {
|
|
71
|
+
const tokens = argv.flatMap(token => {
|
|
72
|
+
const equals = token.startsWith("--") ? token.indexOf("=") : -1;
|
|
73
|
+
return equals >= 0 ? [token.slice(0, equals), token.slice(equals + 1)] : [token];
|
|
74
|
+
});
|
|
75
|
+
const path: string[] = [], options: Record<string, string | boolean> = {}, positionals: string[] = [];
|
|
76
|
+
let definition: CommandDefinition = { description: "amem", commands: COMMANDS };
|
|
77
|
+
let available: Record<string, OptionDefinition> = { ...GLOBAL_OPTIONS, ...ROOT_OPTIONS, ...COMMANDS.list!.options };
|
|
78
|
+
const commandIndexes = new Set<number>();
|
|
79
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
80
|
+
const token = tokens[i]!;
|
|
81
|
+
if (token.startsWith("--")) {
|
|
82
|
+
const option = available[token];
|
|
83
|
+
if (!option) throw new Error(`unknown option ${token}`);
|
|
84
|
+
if (option.value !== undefined) {
|
|
85
|
+
const value = tokens[++i];
|
|
86
|
+
if (value === undefined || value.startsWith("--")) throw new Error(`${token} needs a value`);
|
|
87
|
+
if (Array.isArray(option.value) && !option.value.includes(value)) throw new Error(`${token} takes ${option.value.join(" or ")}`);
|
|
88
|
+
options[token] = value;
|
|
89
|
+
} else options[token] = true;
|
|
90
|
+
} else if (definition.commands) {
|
|
91
|
+
const child: CommandDefinition | undefined = definition.commands[token];
|
|
92
|
+
if (!child) throw new Error(`unknown command ${[...path, token].join(" ")}`);
|
|
93
|
+
commandIndexes.add(i);
|
|
94
|
+
definition = { ...definition, ...child }; path.push(token);
|
|
95
|
+
// Child metadata inherits policy, but its command children belong only to that child.
|
|
96
|
+
definition.commands = child.commands;
|
|
97
|
+
available = completionDefinitions().find(row => row.path.join(" ") === path.join(" "))!.options;
|
|
98
|
+
} else positionals.push(token);
|
|
99
|
+
}
|
|
100
|
+
if (!path.length) {
|
|
101
|
+
const name = options["--version"] === true ? "version" : "list";
|
|
102
|
+
path.push(name); definition = COMMANDS[name]!;
|
|
103
|
+
}
|
|
104
|
+
if (definition.defaultCommand && definition.commands) {
|
|
105
|
+
const name = definition.defaultCommand; definition = { ...definition, ...definition.commands[name]!, commands: undefined }; path.push(name);
|
|
106
|
+
}
|
|
107
|
+
if (positionals.length > (definition.args?.length ?? 0) && !definition.args?.at(-1)?.variadic) throw new Error(`too many arguments for ${path.join(" ")}`);
|
|
108
|
+
for (let i = 0; i < positionals.length; i++) {
|
|
109
|
+
const argument = definition.args?.[i];
|
|
110
|
+
if (argument?.values && !argument.values.includes(positionals[i]!)) throw new Error(`${argument.name} takes ${argument.values.join(" or ")}`);
|
|
111
|
+
}
|
|
112
|
+
const allowed = path[0] === "version" && options["--version"] === true ? completionDefinitions()[0]!.options : completionDefinitions().find(row => row.path.join(" ") === path.join(" "))!.options;
|
|
113
|
+
for (const name of Object.keys(options)) if (!allowed[name]) throw new Error(`unknown option ${name} for ${path.join(" ")}`);
|
|
114
|
+
const rawArgs = [...(path[0] === "goal" ? path.slice(1) : path), ...tokens.filter((arg, index) => arg !== "--policy-free" && !commandIndexes.has(index))];
|
|
115
|
+
return { path, definition, options, positionals, rawArgs };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function commandHelp(): string {
|
|
119
|
+
return ["usage: amem <command> [options]", " [--help] [--version] [--json]", ...completionDefinitions().filter(row => row.path.length).map(row => {
|
|
120
|
+
const names = [...row.path, ...row.args.map(arg => `<${arg.name}>`)].join(" ");
|
|
121
|
+
return ` ${names}${Object.entries(row.options).map(([name, option]) => ` [${name}${option.value === undefined ? "" : ` <${Array.isArray(option.value) ? option.value.join("|") : option.value}>`}]`).join("")}`;
|
|
122
|
+
}), "--policy-free: use ahub memory for the host's privacy policy.", "Mutating commands require a terminal on stdin. Agents use the hub's hub_memory_* tools."].join("\n");
|
|
123
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { completionDefinitions } from "./commands-table.ts";
|
|
2
|
+
|
|
3
|
+
const quote = (text: string) => `'${text.replace(/'/g, `'"'"'`)}'`;
|
|
4
|
+
const print = (values: readonly string[]) => values.length ? `printf '%s\\n' ${values.map(quote).join(" ")}` : "true";
|
|
5
|
+
|
|
6
|
+
/** Static lookup data generated from the exact definitions the CLI uses. No installed executable is queried. */
|
|
7
|
+
function metadata(fish: boolean): string {
|
|
8
|
+
const cases: [string, string[]][] = [];
|
|
9
|
+
for (const definition of completionDefinitions()) {
|
|
10
|
+
const state = definition.path.join(" ");
|
|
11
|
+
cases.push([`children:${state}:`, definition.children]);
|
|
12
|
+
cases.push([`options:${state}:`, Object.keys(definition.options)]);
|
|
13
|
+
for (const [name, option] of Object.entries(definition.options)) {
|
|
14
|
+
if (option.value === undefined) continue;
|
|
15
|
+
cases.push([`type:${state}:${name}`, [typeof option.value === "string" ? option.value : "enum"]]);
|
|
16
|
+
if (typeof option.value !== "string") cases.push([`values:${state}:${name}`, [...option.value]]);
|
|
17
|
+
}
|
|
18
|
+
for (const [index, argument] of definition.args.entries()) {
|
|
19
|
+
cases.push([`argument:${state}:${index}`, [argument.path ? "path" : argument.values ? "enum" : "text"]]);
|
|
20
|
+
if (argument.values) cases.push([`arguments:${state}:${index}`, [...argument.values]]);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (fish) return `function _amem_meta\n switch "$argv[1]:$argv[2]:$argv[3]"\n${cases.map(([key, values]) => ` case ${quote(key)}\n ${print(values)}`).join("\n")}\n end\nend\n`;
|
|
24
|
+
return `_amem_meta() {\n case "$1:$2:$3" in\n${cases.map(([key, values]) => ` ${quote(key)}) ${print(values)} ;;`).join("\n")}\n esac\n}\n`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Bash and zsh share a POSIX-style word walker; the last supplied token is the current prefix. */
|
|
28
|
+
const context = `_amem_context() {
|
|
29
|
+
_amem_state='' _amem_pos=0 _amem_pending='' _amem_cur='' _amem_literal=0
|
|
30
|
+
local word child option i=0
|
|
31
|
+
for word do
|
|
32
|
+
i=$((i + 1))
|
|
33
|
+
if [ "$i" -eq "$#" ]; then _amem_cur=$word; break; fi
|
|
34
|
+
if [ -n "$_amem_pending" ]; then _amem_pending=''; continue; fi
|
|
35
|
+
if [ "$_amem_literal" -eq 0 ]; then
|
|
36
|
+
if [ "$word" = '--' ]; then _amem_literal=1; continue; fi
|
|
37
|
+
case "$word" in
|
|
38
|
+
--*=*) continue ;;
|
|
39
|
+
-*)
|
|
40
|
+
if [ -n "$(_amem_meta type "$_amem_state" "$word")" ]; then _amem_pending=$word; fi
|
|
41
|
+
continue ;;
|
|
42
|
+
esac
|
|
43
|
+
if [ "$_amem_pos" -eq 0 ]; then
|
|
44
|
+
child=''
|
|
45
|
+
while IFS= read -r option; do
|
|
46
|
+
if [ "$option" = "$word" ]; then child=$word; break; fi
|
|
47
|
+
done <<AMEM_CHILDREN
|
|
48
|
+
$(_amem_meta children "$_amem_state" '')
|
|
49
|
+
AMEM_CHILDREN
|
|
50
|
+
if [ -n "$child" ]; then _amem_state="\${_amem_state:+$_amem_state }$child"; _amem_pos=0; continue; fi
|
|
51
|
+
fi
|
|
52
|
+
fi
|
|
53
|
+
_amem_pos=$((_amem_pos + 1))
|
|
54
|
+
done
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_amem_emit() {
|
|
58
|
+
local candidate
|
|
59
|
+
while IFS= read -r candidate; do
|
|
60
|
+
[ -n "$candidate" ] || continue
|
|
61
|
+
case "$candidate" in "$_amem_cur"*) printf '%s\\n' "$_amem_lead$candidate" ;; esac
|
|
62
|
+
done
|
|
63
|
+
}
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
function shCandidates(zsh: boolean): string {
|
|
67
|
+
const files = zsh ? `local pattern="\${(b)_amem_cur}*" candidate
|
|
68
|
+
local -a paths
|
|
69
|
+
paths=( \${~pattern}(N) )
|
|
70
|
+
for candidate in "\${paths[@]}"; do
|
|
71
|
+
[ -d "$candidate" ] && candidate="$candidate/"
|
|
72
|
+
printf '%s\\n' "$_amem_lead$candidate"
|
|
73
|
+
done` : `local candidate
|
|
74
|
+
while IFS= read -r candidate; do
|
|
75
|
+
[ -d "$candidate" ] && candidate="$candidate/"
|
|
76
|
+
printf '%s\\n' "$_amem_lead$candidate"
|
|
77
|
+
done < <(compgen -f -- "$_amem_cur")`;
|
|
78
|
+
return `${context}
|
|
79
|
+
_amem_candidates() {
|
|
80
|
+
${zsh ? "emulate -L zsh" : ":"}
|
|
81
|
+
_amem_context "$@"
|
|
82
|
+
local kind='' _amem_lead=''
|
|
83
|
+
if [ -n "$_amem_pending" ]; then
|
|
84
|
+
kind=$(_amem_meta type "$_amem_state" "$_amem_pending")
|
|
85
|
+
elif [ "$_amem_literal" -eq 0 ]; then
|
|
86
|
+
case "$_amem_cur" in
|
|
87
|
+
--*=*)
|
|
88
|
+
_amem_pending=\${_amem_cur%%=*}
|
|
89
|
+
_amem_lead="$_amem_pending="
|
|
90
|
+
_amem_cur=\${_amem_cur#*=}
|
|
91
|
+
kind=$(_amem_meta type "$_amem_state" "$_amem_pending") ;;
|
|
92
|
+
esac
|
|
93
|
+
fi
|
|
94
|
+
if [ -z "$kind" ] && [ -z "$_amem_pending" ]; then kind=$(_amem_meta argument "$_amem_state" "$_amem_pos"); fi
|
|
95
|
+
if [ "$kind" = path ]; then
|
|
96
|
+
${files}
|
|
97
|
+
elif [ "$kind" = enum ]; then
|
|
98
|
+
if [ -n "$_amem_pending" ]; then _amem_meta values "$_amem_state" "$_amem_pending"
|
|
99
|
+
else _amem_meta arguments "$_amem_state" "$_amem_pos"; fi | _amem_emit
|
|
100
|
+
elif [ -n "$_amem_pending" ]; then
|
|
101
|
+
return 0
|
|
102
|
+
else
|
|
103
|
+
if [ "$_amem_literal" -eq 0 ]; then
|
|
104
|
+
{
|
|
105
|
+
[ "$_amem_pos" -ne 0 ] || _amem_meta children "$_amem_state" ''
|
|
106
|
+
_amem_meta options "$_amem_state" ''
|
|
107
|
+
} | _amem_emit
|
|
108
|
+
fi
|
|
109
|
+
fi
|
|
110
|
+
}
|
|
111
|
+
`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function bash(): string {
|
|
115
|
+
return `# amem bash completion; generated from command definitions.\n${metadata(false)}${shCandidates(false)}
|
|
116
|
+
_amem_complete() {
|
|
117
|
+
local candidate
|
|
118
|
+
COMPREPLY=()
|
|
119
|
+
while IFS= read -r candidate; do COMPREPLY+=("$candidate"); done < <(_amem_candidates "\${COMP_WORDS[@]:1:COMP_CWORD}")
|
|
120
|
+
compopt -o filenames 2>/dev/null || :
|
|
121
|
+
}
|
|
122
|
+
complete -F _amem_complete amem
|
|
123
|
+
`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function zsh(): string {
|
|
127
|
+
return `#compdef amem\n# amem zsh completion; generated from command definitions.\n${metadata(false)}${shCandidates(true)}
|
|
128
|
+
_amem_complete() {
|
|
129
|
+
emulate -L zsh
|
|
130
|
+
local -a candidates
|
|
131
|
+
_amem_context "\${words[@]:1:$((CURRENT - 1))}"
|
|
132
|
+
local kind=''
|
|
133
|
+
if [ -n "$_amem_pending" ]; then kind=$(_amem_meta type "$_amem_state" "$_amem_pending")
|
|
134
|
+
else kind=$(_amem_meta argument "$_amem_state" "$_amem_pos"); fi
|
|
135
|
+
if [ "$kind" = path ]; then _files; return; fi
|
|
136
|
+
candidates=( "\${(@f)$(_amem_candidates "\${words[@]:1:$((CURRENT - 1))}")}" )
|
|
137
|
+
(( \${#candidates} )) && compadd -- "\${candidates[@]}"
|
|
138
|
+
}
|
|
139
|
+
# An autoloaded _amem file is itself the first completion invocation.
|
|
140
|
+
if [[ "\${funcstack[1]-}" == _amem ]]; then
|
|
141
|
+
_amem_complete
|
|
142
|
+
elif (( $+functions[compdef] )); then
|
|
143
|
+
compdef _amem_complete amem
|
|
144
|
+
fi
|
|
145
|
+
`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function fish(): string {
|
|
149
|
+
return `# amem fish completion; generated from command definitions.\n${metadata(true)}
|
|
150
|
+
function _amem_candidates
|
|
151
|
+
set -l state ''
|
|
152
|
+
set -l positional 0
|
|
153
|
+
set -l pending ''
|
|
154
|
+
set -l literal 0
|
|
155
|
+
set -l current ''
|
|
156
|
+
set -l index 0
|
|
157
|
+
for word in $argv
|
|
158
|
+
set index (math $index + 1)
|
|
159
|
+
if test $index -eq (count $argv)
|
|
160
|
+
set current "$word"
|
|
161
|
+
break
|
|
162
|
+
end
|
|
163
|
+
if test -n "$pending"
|
|
164
|
+
set pending ''
|
|
165
|
+
continue
|
|
166
|
+
end
|
|
167
|
+
if test $literal -eq 0
|
|
168
|
+
if test "$word" = --
|
|
169
|
+
set literal 1
|
|
170
|
+
continue
|
|
171
|
+
end
|
|
172
|
+
if string match -q -- '-*' "$word"
|
|
173
|
+
set -l option_kind (_amem_meta type "$state" "$word")
|
|
174
|
+
if not string match -q -- '*=*' "$word"; and test (count $option_kind) -gt 0
|
|
175
|
+
set pending "$word"
|
|
176
|
+
end
|
|
177
|
+
continue
|
|
178
|
+
end
|
|
179
|
+
if test $positional -eq 0; and contains -- "$word" (_amem_meta children "$state" '')
|
|
180
|
+
set state (string trim -- "$state $word")
|
|
181
|
+
set positional 0
|
|
182
|
+
continue
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
set positional (math $positional + 1)
|
|
186
|
+
end
|
|
187
|
+
set -l kind ''
|
|
188
|
+
set -l lead ''
|
|
189
|
+
if test -n "$pending"
|
|
190
|
+
set kind (_amem_meta type "$state" "$pending")
|
|
191
|
+
else if test $literal -eq 0; and string match -q -- '--*=*' "$current"
|
|
192
|
+
set pending (string split -m 1 = -- "$current")[1]
|
|
193
|
+
set lead "$pending="
|
|
194
|
+
set current (string split -m 1 = -- "$current")[2]
|
|
195
|
+
set kind (_amem_meta type "$state" "$pending")
|
|
196
|
+
end
|
|
197
|
+
if test -z "$kind"; and test -z "$pending"
|
|
198
|
+
set kind (_amem_meta argument "$state" "$positional")
|
|
199
|
+
end
|
|
200
|
+
if test "$kind" = path
|
|
201
|
+
for candidate in "$current"*
|
|
202
|
+
if test -d "$candidate"
|
|
203
|
+
set candidate "$candidate/"
|
|
204
|
+
end
|
|
205
|
+
printf '%s\\n' "$lead$candidate"
|
|
206
|
+
end
|
|
207
|
+
else
|
|
208
|
+
set -l candidates
|
|
209
|
+
if test "$kind" = enum
|
|
210
|
+
if test -n "$pending"
|
|
211
|
+
set candidates (_amem_meta values "$state" "$pending")
|
|
212
|
+
else
|
|
213
|
+
set candidates (_amem_meta arguments "$state" "$positional")
|
|
214
|
+
end
|
|
215
|
+
else if test -z "$pending"; and test $literal -eq 0
|
|
216
|
+
if test $positional -eq 0
|
|
217
|
+
set candidates (_amem_meta children "$state" '')
|
|
218
|
+
end
|
|
219
|
+
set -a candidates (_amem_meta options "$state" '')
|
|
220
|
+
end
|
|
221
|
+
set -l prefix_regex (string escape --style=regex -- "$current")
|
|
222
|
+
for candidate in $candidates
|
|
223
|
+
if string match -rq -- "^$prefix_regex" "$candidate"
|
|
224
|
+
printf '%s\\n' "$lead$candidate"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
complete -c amem -f -a '(_amem_candidates (commandline -opc)[2..-1] (commandline -ct))'
|
|
231
|
+
`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Generate a self-contained script; neither generation nor completion consults memory or network. */
|
|
235
|
+
export function completionScript(shell: string): string {
|
|
236
|
+
if (shell === "bash") return bash();
|
|
237
|
+
if (shell === "zsh") return zsh();
|
|
238
|
+
if (shell === "fish") return fish();
|
|
239
|
+
throw new Error(`unsupported completion shell ${shell}; choose zsh, bash or fish`);
|
|
240
|
+
}
|
package/src/index.ts
ADDED