@meshxdata/fops 0.0.4 → 0.0.6
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/package.json +2 -1
- package/src/commands/index.js +163 -1
- package/src/doctor.js +155 -17
- package/src/plugins/bundled/coda/auth.js +79 -0
- package/src/plugins/bundled/coda/client.js +187 -0
- package/src/plugins/bundled/coda/fops.plugin.json +7 -0
- package/src/plugins/bundled/coda/index.js +284 -0
- package/src/plugins/bundled/coda/package.json +3 -0
- package/src/plugins/bundled/coda/skills/coda/SKILL.md +82 -0
- package/src/plugins/bundled/cursor/fops.plugin.json +7 -0
- package/src/plugins/bundled/cursor/index.js +432 -0
- package/src/plugins/bundled/cursor/package.json +1 -0
- package/src/plugins/bundled/cursor/skills/cursor/SKILL.md +48 -0
- package/src/plugins/bundled/fops-plugin-1password/fops.plugin.json +7 -0
- package/src/plugins/bundled/fops-plugin-1password/index.js +239 -0
- package/src/plugins/bundled/fops-plugin-1password/lib/env.js +100 -0
- package/src/plugins/bundled/fops-plugin-1password/lib/op.js +111 -0
- package/src/plugins/bundled/fops-plugin-1password/lib/setup.js +235 -0
- package/src/plugins/bundled/fops-plugin-1password/lib/sync.js +61 -0
- package/src/plugins/bundled/fops-plugin-1password/package.json +1 -0
- package/src/plugins/bundled/fops-plugin-1password/skills/1password/SKILL.md +79 -0
- package/src/plugins/bundled/fops-plugin-ecr/fops.plugin.json +7 -0
- package/src/plugins/bundled/fops-plugin-ecr/index.js +302 -0
- package/src/plugins/bundled/fops-plugin-ecr/lib/aws.js +147 -0
- package/src/plugins/bundled/fops-plugin-ecr/lib/images.js +73 -0
- package/src/plugins/bundled/fops-plugin-ecr/lib/setup.js +180 -0
- package/src/plugins/bundled/fops-plugin-ecr/lib/sync.js +74 -0
- package/src/plugins/bundled/fops-plugin-ecr/package.json +1 -0
- package/src/plugins/bundled/fops-plugin-ecr/skills/ecr/SKILL.md +105 -0
- package/src/plugins/bundled/fops-plugin-memory/fops.plugin.json +7 -0
- package/src/plugins/bundled/fops-plugin-memory/index.js +148 -0
- package/src/plugins/bundled/fops-plugin-memory/lib/relevance.js +72 -0
- package/src/plugins/bundled/fops-plugin-memory/lib/store.js +75 -0
- package/src/plugins/bundled/fops-plugin-memory/package.json +1 -0
- package/src/plugins/bundled/fops-plugin-memory/skills/memory/SKILL.md +58 -0
- package/src/plugins/loader.js +40 -0
- package/src/setup/aws.js +51 -38
- package/src/setup/setup.js +2 -0
- package/src/setup/wizard.js +137 -12
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import inquirer from "inquirer";
|
|
6
|
+
import {
|
|
7
|
+
awsVersion,
|
|
8
|
+
detectEcrRegistry,
|
|
9
|
+
detectSsoProfiles,
|
|
10
|
+
stsIdentity,
|
|
11
|
+
ssoLogin,
|
|
12
|
+
ecrLogin,
|
|
13
|
+
} from "./aws.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read ~/.fops.json config (full file).
|
|
17
|
+
*/
|
|
18
|
+
function readFopsConfig() {
|
|
19
|
+
const configPath = path.join(os.homedir(), ".fops.json");
|
|
20
|
+
try {
|
|
21
|
+
if (fs.existsSync(configPath)) {
|
|
22
|
+
return JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
23
|
+
}
|
|
24
|
+
} catch {}
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Save plugin config into ~/.fops.json (deep-merged).
|
|
30
|
+
*/
|
|
31
|
+
function saveFopsConfig(updates) {
|
|
32
|
+
const configPath = path.join(os.homedir(), ".fops.json");
|
|
33
|
+
const existing = readFopsConfig();
|
|
34
|
+
const merged = { ...existing, ...updates };
|
|
35
|
+
if (updates.plugins) {
|
|
36
|
+
merged.plugins = { ...existing.plugins, ...updates.plugins };
|
|
37
|
+
merged.plugins.entries = {
|
|
38
|
+
...existing?.plugins?.entries,
|
|
39
|
+
...updates.plugins.entries,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
fs.writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Interactive setup wizard for the ECR plugin.
|
|
47
|
+
*/
|
|
48
|
+
export async function runSetupWizard(root) {
|
|
49
|
+
console.log(chalk.bold.cyan("\n ECR Plugin Setup\n"));
|
|
50
|
+
|
|
51
|
+
// Step 1: Check AWS CLI
|
|
52
|
+
console.log(chalk.dim(" Checking AWS CLI..."));
|
|
53
|
+
let version = await awsVersion();
|
|
54
|
+
if (!version) {
|
|
55
|
+
console.log(chalk.red(" ✗ AWS CLI not found."));
|
|
56
|
+
const { install } = await inquirer.prompt([{
|
|
57
|
+
type: "confirm", name: "install", message: "Install via Homebrew?", default: true,
|
|
58
|
+
}]);
|
|
59
|
+
if (install) {
|
|
60
|
+
console.log(chalk.cyan(" ▶ brew install awscli"));
|
|
61
|
+
const { execa: execaFn } = await import("execa");
|
|
62
|
+
try {
|
|
63
|
+
await execaFn("brew", ["install", "awscli"], { stdio: "inherit", timeout: 120000 });
|
|
64
|
+
version = await awsVersion();
|
|
65
|
+
} catch (err) {
|
|
66
|
+
console.log(chalk.red(` Install failed: ${err.message}`));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
console.log(chalk.dim(" Install manually: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html"));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
console.log(chalk.green(` ✓ AWS CLI v${version}`));
|
|
75
|
+
|
|
76
|
+
// Step 2: Check for existing SSO profiles
|
|
77
|
+
console.log(chalk.dim("\n Checking SSO profiles..."));
|
|
78
|
+
let profiles = detectSsoProfiles();
|
|
79
|
+
let selectedProfile;
|
|
80
|
+
|
|
81
|
+
if (profiles.length > 0) {
|
|
82
|
+
console.log(chalk.green(` ✓ Found ${profiles.length} SSO profile(s)`));
|
|
83
|
+
if (profiles.length === 1) {
|
|
84
|
+
selectedProfile = profiles[0].name;
|
|
85
|
+
console.log(chalk.dim(` Using profile: ${selectedProfile}`));
|
|
86
|
+
} else {
|
|
87
|
+
const choices = profiles.map((p) => ({ name: p.name, value: p.name }));
|
|
88
|
+
const { profile } = await inquirer.prompt([{
|
|
89
|
+
type: "list", name: "profile", message: "Select AWS profile:", choices,
|
|
90
|
+
}]);
|
|
91
|
+
selectedProfile = profile;
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
// No profiles — prompt for SSO config
|
|
95
|
+
console.log(chalk.yellow(" No SSO profiles found. Let's configure one."));
|
|
96
|
+
|
|
97
|
+
const answers = await inquirer.prompt([
|
|
98
|
+
{ type: "input", name: "sessionName", message: "SSO session name:", default: "me-central-1" },
|
|
99
|
+
{ type: "input", name: "ssoStartUrl", message: "SSO start URL:", validate: (v) => v?.trim() ? true : "Required." },
|
|
100
|
+
{ type: "input", name: "ssoRegion", message: "SSO region:", default: "us-east-1" },
|
|
101
|
+
{ type: "input", name: "accountId", message: "AWS account ID:", validate: (v) => /^\d{12}$/.test(v?.trim()) ? true : "Must be 12 digits." },
|
|
102
|
+
{ type: "input", name: "roleName", message: "SSO role name:", default: "AdministratorAccess" },
|
|
103
|
+
{ type: "input", name: "profileName", message: "Profile name:", default: "dev" },
|
|
104
|
+
{ type: "input", name: "region", message: "Default region:", default: (a) => a.ssoRegion },
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
// Ensure ~/.aws directory exists
|
|
108
|
+
const awsDir = path.join(os.homedir(), ".aws");
|
|
109
|
+
if (!fs.existsSync(awsDir)) fs.mkdirSync(awsDir, { mode: 0o700 });
|
|
110
|
+
|
|
111
|
+
const configPath = path.join(awsDir, "config");
|
|
112
|
+
const block = `[sso-session ${answers.sessionName.trim()}]
|
|
113
|
+
sso_start_url = ${answers.ssoStartUrl.trim()}
|
|
114
|
+
sso_region = ${answers.ssoRegion.trim()}
|
|
115
|
+
sso_registration_scopes = sso:account:access
|
|
116
|
+
|
|
117
|
+
[profile ${answers.profileName.trim()}]
|
|
118
|
+
sso_session = ${answers.sessionName.trim()}
|
|
119
|
+
sso_account_id = ${answers.accountId.trim()}
|
|
120
|
+
sso_role_name = ${answers.roleName.trim()}
|
|
121
|
+
region = ${answers.region.trim()}
|
|
122
|
+
output = json
|
|
123
|
+
`;
|
|
124
|
+
fs.writeFileSync(configPath, block);
|
|
125
|
+
console.log(chalk.green(` ✓ Written to ~/.aws/config (profile: ${answers.profileName})`));
|
|
126
|
+
selectedProfile = answers.profileName.trim();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Step 3: SSO login
|
|
130
|
+
console.log(chalk.dim("\n Checking AWS session..."));
|
|
131
|
+
const sts = await stsIdentity(selectedProfile);
|
|
132
|
+
if (!sts.valid) {
|
|
133
|
+
console.log(chalk.yellow(" Session expired — logging in..."));
|
|
134
|
+
await ssoLogin(selectedProfile);
|
|
135
|
+
|
|
136
|
+
const retry = await stsIdentity(selectedProfile);
|
|
137
|
+
if (!retry.valid) {
|
|
138
|
+
console.log(chalk.red(" ✗ SSO login failed."));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
console.log(chalk.green(" ✓ AWS session valid"));
|
|
143
|
+
|
|
144
|
+
// Step 4: ECR docker login
|
|
145
|
+
const ecr = detectEcrRegistry(root);
|
|
146
|
+
if (ecr) {
|
|
147
|
+
console.log(chalk.dim("\n Logging in to ECR..."));
|
|
148
|
+
const login = await ecrLogin(ecr.accountId, ecr.region, selectedProfile);
|
|
149
|
+
if (login.success) {
|
|
150
|
+
console.log(chalk.green(` ✓ ECR authenticated (${login.url})`));
|
|
151
|
+
} else {
|
|
152
|
+
console.log(chalk.red(` ✗ ECR login failed for ${login.url}`));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Step 5: Auto-login preference
|
|
157
|
+
const { autoLogin } = await inquirer.prompt([{
|
|
158
|
+
type: "confirm", name: "autoLogin", message: "Auto-login to ECR before `fops up`?", default: true,
|
|
159
|
+
}]);
|
|
160
|
+
|
|
161
|
+
// Step 6: Save config
|
|
162
|
+
saveFopsConfig({
|
|
163
|
+
plugins: {
|
|
164
|
+
entries: {
|
|
165
|
+
"fops-plugin-ecr": {
|
|
166
|
+
enabled: true,
|
|
167
|
+
config: {
|
|
168
|
+
profile: selectedProfile,
|
|
169
|
+
autoLogin,
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
console.log(chalk.green("\n ✓ Config saved to ~/.fops.json"));
|
|
177
|
+
console.log(chalk.dim(` profile: ${selectedProfile}`));
|
|
178
|
+
console.log(chalk.dim(` autoLogin: ${autoLogin}`));
|
|
179
|
+
console.log(chalk.bold.green("\n Setup complete! Run: fops ecr sync\n"));
|
|
180
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { execa } from "execa";
|
|
3
|
+
import {
|
|
4
|
+
detectEcrRegistry,
|
|
5
|
+
detectSsoProfiles,
|
|
6
|
+
stsIdentity,
|
|
7
|
+
ssoLogin,
|
|
8
|
+
ecrLogin,
|
|
9
|
+
} from "./aws.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Authenticate to ECR and pull latest images.
|
|
13
|
+
*
|
|
14
|
+
* 1. Detect ECR registry from compose file
|
|
15
|
+
* 2. Verify SSO session (auto-login if expired)
|
|
16
|
+
* 3. ECR docker login
|
|
17
|
+
* 4. docker compose pull from project root
|
|
18
|
+
* 5. Report results
|
|
19
|
+
*/
|
|
20
|
+
export async function syncImages(root, config = {}) {
|
|
21
|
+
// 1. Detect registry
|
|
22
|
+
const ecr = detectEcrRegistry(root);
|
|
23
|
+
if (!ecr) {
|
|
24
|
+
console.log(chalk.yellow(" No ECR images found in docker-compose.yaml."));
|
|
25
|
+
return { success: false, reason: "no-ecr-images" };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ecrUrl = `${ecr.accountId}.dkr.ecr.${ecr.region}.amazonaws.com`;
|
|
29
|
+
console.log(chalk.dim(` Registry: ${ecrUrl}`));
|
|
30
|
+
|
|
31
|
+
// 2. Determine profile
|
|
32
|
+
const profile = config.profile || detectSsoProfiles()[0]?.name || null;
|
|
33
|
+
if (profile) {
|
|
34
|
+
console.log(chalk.dim(` Profile: ${profile}`));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 3. Verify SSO session
|
|
38
|
+
const sts = await stsIdentity(profile);
|
|
39
|
+
if (!sts.valid) {
|
|
40
|
+
console.log(chalk.yellow(" AWS session expired — logging in via SSO..."));
|
|
41
|
+
await ssoLogin(profile);
|
|
42
|
+
|
|
43
|
+
const retry = await stsIdentity(profile);
|
|
44
|
+
if (!retry.valid) {
|
|
45
|
+
console.log(chalk.red(" ✗ SSO login failed. Run: aws sso login"));
|
|
46
|
+
return { success: false, reason: "sso-failed" };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
console.log(chalk.green(" ✓ AWS session valid"));
|
|
50
|
+
|
|
51
|
+
// 4. ECR docker login
|
|
52
|
+
const login = await ecrLogin(ecr.accountId, ecr.region, profile);
|
|
53
|
+
if (!login.success) {
|
|
54
|
+
console.log(chalk.red(" ✗ ECR docker login failed"));
|
|
55
|
+
return { success: false, reason: "ecr-login-failed" };
|
|
56
|
+
}
|
|
57
|
+
console.log(chalk.green(` ✓ ECR authenticated`));
|
|
58
|
+
|
|
59
|
+
// 5. Pull images
|
|
60
|
+
console.log(chalk.cyan("\n Pulling images...\n"));
|
|
61
|
+
const { exitCode } = await execa(
|
|
62
|
+
"docker",
|
|
63
|
+
["compose", "pull"],
|
|
64
|
+
{ cwd: root, stdio: "inherit", reject: false, timeout: 600_000 },
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
if (exitCode === 0) {
|
|
68
|
+
console.log(chalk.green("\n ✓ All images pulled successfully."));
|
|
69
|
+
return { success: true };
|
|
70
|
+
} else {
|
|
71
|
+
console.log(chalk.yellow("\n ⚠ Some images may have failed to pull."));
|
|
72
|
+
return { success: false, reason: "pull-failed" };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "type": "module" }
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ecr
|
|
3
|
+
description: AWS ECR authentication and container image management
|
|
4
|
+
requires: aws
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## ECR Plugin for fops
|
|
8
|
+
|
|
9
|
+
Manages AWS ECR authentication and container image pulls for the Foundation stack.
|
|
10
|
+
|
|
11
|
+
## Commands
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
fops ecr setup # Interactive wizard: install AWS CLI, configure SSO, ECR login
|
|
15
|
+
fops ecr sync # Authenticate to ECR and pull latest images
|
|
16
|
+
fops ecr status # Show AWS CLI, SSO session, ECR auth, image freshness
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Auth Flow
|
|
20
|
+
|
|
21
|
+
ECR authentication uses AWS SSO:
|
|
22
|
+
|
|
23
|
+
1. **SSO login**: `aws sso login --profile <name>` — opens browser for authentication
|
|
24
|
+
2. **Get ECR password**: `aws ecr get-login-password --region <region>`
|
|
25
|
+
3. **Docker login**: `docker login --username AWS --password-stdin <registry-url>`
|
|
26
|
+
|
|
27
|
+
The `before:up` hook handles this automatically when `autoLogin: true`.
|
|
28
|
+
|
|
29
|
+
## Config
|
|
30
|
+
|
|
31
|
+
Stored in `~/.fops.json`:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"plugins": {
|
|
36
|
+
"entries": {
|
|
37
|
+
"fops-plugin-ecr": {
|
|
38
|
+
"enabled": true,
|
|
39
|
+
"config": {
|
|
40
|
+
"profile": "dev",
|
|
41
|
+
"autoLogin": true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- `profile` — AWS CLI profile name (must have `sso_session` in `~/.aws/config`)
|
|
50
|
+
- `autoLogin` — When true, auto-authenticate to ECR before `fops up`
|
|
51
|
+
|
|
52
|
+
## AWS SSO Configuration
|
|
53
|
+
|
|
54
|
+
The plugin reads `~/.aws/config` for SSO profiles. A typical setup:
|
|
55
|
+
|
|
56
|
+
```ini
|
|
57
|
+
[sso-session meshx]
|
|
58
|
+
sso_start_url = https://myorg.awsapps.com/start
|
|
59
|
+
sso_region = us-east-1
|
|
60
|
+
sso_registration_scopes = sso:account:access
|
|
61
|
+
|
|
62
|
+
[profile dev]
|
|
63
|
+
sso_session = meshx
|
|
64
|
+
sso_account_id = 676206939231
|
|
65
|
+
sso_role_name = AdministratorAccess
|
|
66
|
+
region = me-central-1
|
|
67
|
+
output = json
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## ECR Registry
|
|
71
|
+
|
|
72
|
+
Images in `docker-compose.yaml` follow the pattern:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
676206939231.dkr.ecr.me-central-1.amazonaws.com/foundation/<service>:<tag>
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The plugin auto-detects the account ID and region from compose image references.
|
|
79
|
+
|
|
80
|
+
## Setup
|
|
81
|
+
|
|
82
|
+
Run `fops ecr setup` to:
|
|
83
|
+
|
|
84
|
+
1. Check/install AWS CLI (offers Homebrew install)
|
|
85
|
+
2. Detect or create SSO profile in `~/.aws/config`
|
|
86
|
+
3. Authenticate via SSO
|
|
87
|
+
4. Docker login to ECR
|
|
88
|
+
5. Set auto-login preference
|
|
89
|
+
6. Save config to `~/.fops.json`
|
|
90
|
+
|
|
91
|
+
## Image Freshness
|
|
92
|
+
|
|
93
|
+
`fops ecr status` checks local Docker images against ECR references. Images older than 7 days are flagged as stale. Run `fops ecr sync` to pull fresh copies.
|
|
94
|
+
|
|
95
|
+
## Troubleshooting
|
|
96
|
+
|
|
97
|
+
**AWS CLI not found**: Install via `brew install awscli` or see https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html
|
|
98
|
+
|
|
99
|
+
**SSO session expired**: Run `fops ecr sync` (auto-logins) or `aws sso login --profile dev`
|
|
100
|
+
|
|
101
|
+
**ECR login failed**: Check that your SSO profile has permission to access ECR. Verify with `aws sts get-caller-identity --profile dev`
|
|
102
|
+
|
|
103
|
+
**Images not pulling**: Ensure Docker is running and you're authenticated. Run `fops ecr status` to diagnose. Check that the registry URL matches the images in `docker-compose.yaml`.
|
|
104
|
+
|
|
105
|
+
**Auto-login not firing**: Check `~/.fops.json` has `autoLogin: true` under the plugin config.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { loadMemories, addMemory, removeMemory, clearMemories } from "./lib/store.js";
|
|
3
|
+
import { searchMemories } from "./lib/relevance.js";
|
|
4
|
+
|
|
5
|
+
export function register(api) {
|
|
6
|
+
// ── Command: fops memory ───────────────────────────
|
|
7
|
+
api.registerCommand((program) => {
|
|
8
|
+
const cmd = program
|
|
9
|
+
.command("memory")
|
|
10
|
+
.alias("mem")
|
|
11
|
+
.description("Manage agent memory across sessions");
|
|
12
|
+
|
|
13
|
+
// fops memory save <text> [--tag <tag>...]
|
|
14
|
+
cmd
|
|
15
|
+
.command("save <text...>")
|
|
16
|
+
.description("Save a memory")
|
|
17
|
+
.option("-t, --tag <tags...>", "tags for categorization")
|
|
18
|
+
.action((textParts, opts) => {
|
|
19
|
+
const text = textParts.join(" ");
|
|
20
|
+
const tags = opts.tag || [];
|
|
21
|
+
const entry = addMemory(text, tags);
|
|
22
|
+
console.log(chalk.green(` ✓ Saved memory ${entry.id}`));
|
|
23
|
+
console.log(chalk.gray(` "${entry.text}"`));
|
|
24
|
+
if (tags.length) console.log(chalk.gray(` tags: ${tags.join(", ")}`));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// fops memory list
|
|
28
|
+
cmd
|
|
29
|
+
.command("list")
|
|
30
|
+
.description("List all memories")
|
|
31
|
+
.action(() => {
|
|
32
|
+
const memories = loadMemories();
|
|
33
|
+
if (memories.length === 0) {
|
|
34
|
+
console.log(chalk.gray(" No memories saved."));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
console.log(chalk.bold.cyan(`\n Agent Memory (${memories.length})\n`));
|
|
38
|
+
for (const m of memories) {
|
|
39
|
+
const age = daysSince(m.createdAt);
|
|
40
|
+
const tags = m.tags?.length ? chalk.blue(` [${m.tags.join(", ")}]`) : "";
|
|
41
|
+
console.log(chalk.gray(` ${m.id}`) + ` ${m.text}` + tags + chalk.gray(` — ${age}`));
|
|
42
|
+
}
|
|
43
|
+
console.log("");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// fops memory search <query>
|
|
47
|
+
cmd
|
|
48
|
+
.command("search <query...>")
|
|
49
|
+
.description("Search memories by relevance")
|
|
50
|
+
.action((queryParts) => {
|
|
51
|
+
const query = queryParts.join(" ");
|
|
52
|
+
const memories = loadMemories();
|
|
53
|
+
const results = searchMemories(memories, query);
|
|
54
|
+
if (results.length === 0) {
|
|
55
|
+
console.log(chalk.gray(" No matching memories."));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
console.log(chalk.bold.cyan(`\n Search: "${query}" (${results.length} match${results.length > 1 ? "es" : ""})\n`));
|
|
59
|
+
for (const { memory: m, score } of results) {
|
|
60
|
+
const tags = m.tags?.length ? chalk.blue(` [${m.tags.join(", ")}]`) : "";
|
|
61
|
+
const pct = Math.round(score * 100);
|
|
62
|
+
console.log(chalk.gray(` ${m.id} (${pct}%)`) + ` ${m.text}` + tags);
|
|
63
|
+
}
|
|
64
|
+
console.log("");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// fops memory forget <id>
|
|
68
|
+
cmd
|
|
69
|
+
.command("forget <id>")
|
|
70
|
+
.description("Remove a memory by ID")
|
|
71
|
+
.action((id) => {
|
|
72
|
+
const removed = removeMemory(id);
|
|
73
|
+
if (removed) {
|
|
74
|
+
console.log(chalk.green(` ✓ Removed: "${removed.text}"`));
|
|
75
|
+
} else {
|
|
76
|
+
console.log(chalk.red(` ✗ No memory found with ID: ${id}`));
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// fops memory clear
|
|
81
|
+
cmd
|
|
82
|
+
.command("clear")
|
|
83
|
+
.description("Clear all memories")
|
|
84
|
+
.action(() => {
|
|
85
|
+
const count = clearMemories();
|
|
86
|
+
console.log(chalk.green(` ✓ Cleared ${count} memory(s).`));
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── Knowledge source — inject relevant memories ────
|
|
91
|
+
api.registerKnowledgeSource({
|
|
92
|
+
name: "Agent Memory",
|
|
93
|
+
description: "Persistent memories from past sessions",
|
|
94
|
+
search(query) {
|
|
95
|
+
const memories = loadMemories();
|
|
96
|
+
if (memories.length === 0) return [];
|
|
97
|
+
|
|
98
|
+
const results = searchMemories(memories, query, { maxResults: 5, threshold: 0.15 });
|
|
99
|
+
if (results.length === 0) return [];
|
|
100
|
+
|
|
101
|
+
const lines = results.map(({ memory: m, score }) => {
|
|
102
|
+
const tags = m.tags?.length ? ` [${m.tags.join(", ")}]` : "";
|
|
103
|
+
const age = daysSince(m.createdAt);
|
|
104
|
+
return `- ${m.text}${tags} (${age})`;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return [
|
|
108
|
+
{
|
|
109
|
+
title: "Memories from past sessions",
|
|
110
|
+
content: [
|
|
111
|
+
"## Agent Memory",
|
|
112
|
+
"",
|
|
113
|
+
"These are things you remembered from previous sessions:",
|
|
114
|
+
"",
|
|
115
|
+
...lines,
|
|
116
|
+
"",
|
|
117
|
+
"Use these memories to inform your responses. If a memory is outdated or wrong, suggest `fops memory forget <id>` to remove it.",
|
|
118
|
+
].join("\n"),
|
|
119
|
+
score: 0.85,
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ── Auto-run pattern — let agent save memories without confirmation ──
|
|
126
|
+
api.registerAutoRunPattern("fops memory");
|
|
127
|
+
api.registerAutoRunPattern("fops mem");
|
|
128
|
+
|
|
129
|
+
// ── Doctor check ───────────────────────────────────
|
|
130
|
+
api.registerDoctorCheck({
|
|
131
|
+
name: "Agent Memory",
|
|
132
|
+
fn: async (ok, warn) => {
|
|
133
|
+
const memories = loadMemories();
|
|
134
|
+
if (memories.length > 0) {
|
|
135
|
+
ok(`Agent memory`, `${memories.length} memory(s) stored`);
|
|
136
|
+
} else {
|
|
137
|
+
ok("Agent memory", "empty — agent will save learnings over time");
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function daysSince(isoDate) {
|
|
144
|
+
const days = Math.floor((Date.now() - new Date(isoDate).getTime()) / 86400000);
|
|
145
|
+
if (days === 0) return "today";
|
|
146
|
+
if (days === 1) return "1d ago";
|
|
147
|
+
return `${days}d ago`;
|
|
148
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const STOP_WORDS = new Set([
|
|
2
|
+
"a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
|
|
3
|
+
"have", "has", "had", "do", "does", "did", "will", "would", "could",
|
|
4
|
+
"should", "may", "might", "shall", "can", "need", "must",
|
|
5
|
+
"i", "me", "my", "we", "our", "you", "your", "he", "she", "it",
|
|
6
|
+
"they", "them", "this", "that", "these", "those", "what", "which",
|
|
7
|
+
"who", "whom", "how", "when", "where", "why",
|
|
8
|
+
"and", "or", "but", "not", "no", "if", "then", "so", "than",
|
|
9
|
+
"to", "of", "in", "for", "on", "with", "at", "by", "from", "up",
|
|
10
|
+
"about", "into", "through", "after", "before", "between",
|
|
11
|
+
"all", "any", "some", "each", "every", "both", "few", "more",
|
|
12
|
+
"just", "also", "very", "too", "quite", "really", "only",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Tokenize text into meaningful words.
|
|
17
|
+
*/
|
|
18
|
+
function tokenize(text) {
|
|
19
|
+
return text
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[^a-z0-9\-_/.]+/g, " ")
|
|
22
|
+
.split(/\s+/)
|
|
23
|
+
.filter((w) => w.length > 1 && !STOP_WORDS.has(w));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Score a memory against a query.
|
|
28
|
+
* Returns 0-1 score based on word overlap + tag boost.
|
|
29
|
+
*/
|
|
30
|
+
function scoreMemory(memory, queryTokens) {
|
|
31
|
+
const memTokens = new Set(tokenize(memory.text));
|
|
32
|
+
// Add tags as tokens too
|
|
33
|
+
for (const tag of memory.tags || []) {
|
|
34
|
+
for (const t of tokenize(tag)) memTokens.add(t);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (queryTokens.length === 0 || memTokens.size === 0) return 0;
|
|
38
|
+
|
|
39
|
+
let matches = 0;
|
|
40
|
+
for (const qt of queryTokens) {
|
|
41
|
+
// Exact match or substring match for compound terms
|
|
42
|
+
if (memTokens.has(qt)) {
|
|
43
|
+
matches++;
|
|
44
|
+
} else {
|
|
45
|
+
for (const mt of memTokens) {
|
|
46
|
+
if (mt.includes(qt) || qt.includes(mt)) {
|
|
47
|
+
matches += 0.5;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return matches / queryTokens.length;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Search memories by relevance to a query.
|
|
59
|
+
* Returns top-K memories sorted by score, filtered by threshold.
|
|
60
|
+
*/
|
|
61
|
+
export function searchMemories(memories, query, { maxResults = 5, threshold = 0.2 } = {}) {
|
|
62
|
+
const queryTokens = tokenize(query);
|
|
63
|
+
if (queryTokens.length === 0) return [];
|
|
64
|
+
|
|
65
|
+
const scored = memories
|
|
66
|
+
.map((m) => ({ memory: m, score: scoreMemory(m, queryTokens) }))
|
|
67
|
+
.filter((r) => r.score >= threshold)
|
|
68
|
+
.sort((a, b) => b.score - a.score)
|
|
69
|
+
.slice(0, maxResults);
|
|
70
|
+
|
|
71
|
+
return scored;
|
|
72
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
|
|
6
|
+
const MEMORY_DIR = path.join(os.homedir(), ".fops", "memory");
|
|
7
|
+
const MEMORY_FILE = path.join(MEMORY_DIR, "memories.json");
|
|
8
|
+
|
|
9
|
+
function ensureDir() {
|
|
10
|
+
if (!fs.existsSync(MEMORY_DIR)) {
|
|
11
|
+
fs.mkdirSync(MEMORY_DIR, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Load all memories from disk.
|
|
17
|
+
* Returns [] if file doesn't exist.
|
|
18
|
+
*/
|
|
19
|
+
export function loadMemories() {
|
|
20
|
+
try {
|
|
21
|
+
if (fs.existsSync(MEMORY_FILE)) {
|
|
22
|
+
return JSON.parse(fs.readFileSync(MEMORY_FILE, "utf8"));
|
|
23
|
+
}
|
|
24
|
+
} catch {}
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Save full memories array to disk.
|
|
30
|
+
*/
|
|
31
|
+
function saveMemories(memories) {
|
|
32
|
+
ensureDir();
|
|
33
|
+
fs.writeFileSync(MEMORY_FILE, JSON.stringify(memories, null, 2) + "\n");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Add a new memory.
|
|
38
|
+
* Returns the created memory object.
|
|
39
|
+
*/
|
|
40
|
+
export function addMemory(text, tags = []) {
|
|
41
|
+
const memories = loadMemories();
|
|
42
|
+
const entry = {
|
|
43
|
+
id: crypto.randomUUID().split("-")[0],
|
|
44
|
+
text: text.trim(),
|
|
45
|
+
tags: tags.map((t) => t.trim().toLowerCase()).filter(Boolean),
|
|
46
|
+
createdAt: new Date().toISOString(),
|
|
47
|
+
};
|
|
48
|
+
memories.push(entry);
|
|
49
|
+
saveMemories(memories);
|
|
50
|
+
return entry;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Remove a memory by id (prefix match).
|
|
55
|
+
* Returns the removed entry or null.
|
|
56
|
+
*/
|
|
57
|
+
export function removeMemory(id) {
|
|
58
|
+
const memories = loadMemories();
|
|
59
|
+
const idx = memories.findIndex((m) => m.id === id || m.id.startsWith(id));
|
|
60
|
+
if (idx === -1) return null;
|
|
61
|
+
const [removed] = memories.splice(idx, 1);
|
|
62
|
+
saveMemories(memories);
|
|
63
|
+
return removed;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Clear all memories.
|
|
68
|
+
* Returns the count of removed entries.
|
|
69
|
+
*/
|
|
70
|
+
export function clearMemories() {
|
|
71
|
+
const memories = loadMemories();
|
|
72
|
+
const count = memories.length;
|
|
73
|
+
saveMemories([]);
|
|
74
|
+
return count;
|
|
75
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "type": "module" }
|