@xmemo/skill 1.1.25 → 1.1.26

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 CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2025-2026 Yonro
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.
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Yonro
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
@@ -20,7 +20,7 @@ npx @xmemo/skill install
20
20
  ## Commands
21
21
 
22
22
  - `xmemo-skill install`: Install the bundled skill files
23
- - `xmemo-skill version`: Print the skill version (`1.1.25`)
23
+ - `xmemo-skill version`: Print the skill version (`1.1.26`)
24
24
  - `xmemo-skill help`: Display command usage and options
25
25
 
26
26
  ## Package Integrity
package/bin/install.mjs CHANGED
@@ -1,251 +1,251 @@
1
- #!/usr/bin/env node
2
-
3
- import fs from 'node:fs/promises';
4
- import { realpathSync } from 'node:fs';
5
- import path from 'node:path';
6
- import { randomUUID } from 'node:crypto';
7
- import { fileURLToPath } from 'node:url';
8
- import process from 'node:process';
9
-
10
- const PACKAGE_NAME = '@xmemo/skill';
11
- const DEFAULT_INSTALL_DIR = 'xmemo-skill';
12
-
13
- export async function locateSkillSource(metaUrl = import.meta.url) {
14
- const currentDir = path.dirname(fileURLToPath(metaUrl));
15
- const candidateStaged = path.resolve(currentDir, '..', 'skill');
16
-
17
- const stagedStat = await fs.stat(candidateStaged).catch(() => null);
18
- if (stagedStat?.isDirectory()) return candidateStaged;
19
-
20
- throw new Error(`Could not locate bundled skill directory: ${candidateStaged}`);
21
- }
22
-
23
- export async function readSkillVersion(source) {
24
- for (const required of ['SKILL.md', path.join('scripts', 'xmemo-skill.mjs')]) {
25
- const stat = await fs.stat(path.join(source, required)).catch(() => null);
26
- if (!stat?.isFile()) throw new Error(`Missing required Skill file: ${required}`);
27
- }
28
- const runtime = await fs.readFile(path.join(source, 'scripts', 'xmemo-skill.mjs'), 'utf8');
29
- const match = runtime.match(/const SKILL_VERSION = '([^']+)';/);
30
- if (!match?.[1]) throw new Error('The XMemo Skill version could not be determined from scripts/xmemo-skill.mjs.');
31
- return match[1];
32
- }
33
-
34
- function resolveRealPath(p) {
35
- try {
36
- return realpathSync(p);
37
- } catch {
38
- const parent = path.dirname(p);
39
- try {
40
- return path.join(realpathSync(parent), path.basename(p));
41
- } catch {
42
- return path.resolve(p);
43
- }
44
- }
45
- }
46
-
47
- export function assertSafeTarget(packageRoot, source, target) {
48
- if (target === path.parse(target).root) {
49
- throw new Error('Refusing to install a Skill into a filesystem root.');
50
- }
51
- const realPkg = resolveRealPath(packageRoot);
52
- const realSource = resolveRealPath(source);
53
- const realTarget = resolveRealPath(target);
54
-
55
- const relPkg = path.relative(realPkg, realTarget);
56
- if (!relPkg || (!relPkg.startsWith('..') && !path.isAbsolute(relPkg))) {
57
- throw new Error('Skill destination cannot be the package root or a directory inside it.');
58
- }
59
- const relSource = path.relative(realSource, realTarget);
60
- if (!relSource || (!relSource.startsWith('..') && !path.isAbsolute(relSource))) {
61
- throw new Error('Skill destination cannot be the skill source or a directory inside it.');
62
- }
63
- }
64
-
65
- export async function pathExists(target) {
66
- try {
67
- await fs.access(target);
68
- return true;
69
- } catch (error) {
70
- if (error?.code === 'ENOENT') return false;
71
- throw error;
72
- }
73
- }
74
-
75
- export async function atomicInstall(source, target, replace, { rename = fs.rename } = {}) {
76
- const parent = path.dirname(target);
77
- const base = path.basename(target);
78
- const nonce = `${process.pid}-${randomUUID()}`;
79
- const staging = path.join(parent, `.${base}.xmemo-staging-${nonce}`);
80
- const backup = path.join(parent, `.${base}.xmemo-backup-${nonce}`);
81
- let movedExisting = false;
82
-
83
- await fs.mkdir(parent, { recursive: true });
84
- try {
85
- await fs.cp(source, staging, { recursive: true, errorOnExist: true, force: false });
86
- if (replace) {
87
- await rename(target, backup);
88
- movedExisting = true;
89
- }
90
- await rename(staging, target);
91
- if (movedExisting) {
92
- await fs.rm(backup, { recursive: true, force: true });
93
- movedExisting = false;
94
- }
95
- } catch (error) {
96
- if (movedExisting && !(await pathExists(target))) {
97
- await rename(backup, target).catch(() => {});
98
- }
99
- throw error;
100
- } finally {
101
- await fs.rm(staging, { recursive: true, force: true }).catch(() => {});
102
- }
103
- }
104
-
105
- export function parseInstallOptions(args) {
106
- const flags = new Set(['--dry-run', '--force', '--json']);
107
- const seen = new Set();
108
- let target = undefined;
109
- let dryRun = false;
110
- let force = false;
111
- let json = false;
112
-
113
- for (let i = 0; i < args.length; i += 1) {
114
- const token = args[i];
115
- if (token === '--help' || token === '-h') {
116
- return { help: true };
117
- }
118
- if (token === '--target') {
119
- if (seen.has(token)) throw new Error('Duplicate option: --target.');
120
- const val = args[i + 1];
121
- if (!val || val.startsWith('-')) throw new Error('Option --target requires a value.');
122
- target = val;
123
- seen.add(token);
124
- i += 1;
125
- continue;
126
- }
127
- if (!flags.has(token)) {
128
- throw new Error(`Unknown skill install option: ${token}`);
129
- }
130
- if (seen.has(token)) {
131
- throw new Error(`Duplicate option: ${token}.`);
132
- }
133
- seen.add(token);
134
- if (token === '--dry-run') dryRun = true;
135
- if (token === '--force') force = true;
136
- if (token === '--json') json = true;
137
- }
138
-
139
- return { help: false, target, dryRun, force, json };
140
- }
141
-
142
- export function printHelp(stdout = console.log) {
143
- stdout('XMemo Skill installer (@xmemo/skill)');
144
- stdout('');
145
- stdout('Usage:');
146
- stdout(' xmemo-skill install [--target <dir>] [--dry-run] [--force] [--json]');
147
- stdout(' xmemo-skill version [--json]');
148
- stdout(' xmemo-skill help');
149
- stdout('');
150
- stdout('Commands:');
151
- stdout(' install Install the XMemo Skill locally (offline, zero-network)');
152
- stdout(' version Print the skill version');
153
- stdout(' help Print this help message');
154
- stdout('');
155
- stdout('Options:');
156
- stdout(' --target <dir> Installation directory (default: xmemo-skill, or $XMEMO_SKILL_DIR)');
157
- stdout(' --dry-run Simulate installation without writing files');
158
- stdout(' --force Overwrite destination directory if it already exists');
159
- stdout(' --json Output results as JSON');
160
- }
161
-
162
- export async function runCli(argv = process.argv.slice(2), { cwd = process.cwd(), env = process.env, metaUrl = import.meta.url } = {}) {
163
- const subcommand = argv[0] ?? 'help';
164
-
165
- if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
166
- printHelp();
167
- return 0;
168
- }
169
-
170
- const source = await locateSkillSource(metaUrl);
171
- const packageRoot = path.resolve(path.dirname(fileURLToPath(metaUrl)), '..');
172
- const skillVersion = await readSkillVersion(source);
173
-
174
- if (subcommand === 'version' || subcommand === '--version' || subcommand === '-v') {
175
- const isJson = argv.includes('--json');
176
- if (isJson) {
177
- console.log(JSON.stringify({ package: PACKAGE_NAME, version: skillVersion, skillVersion }, null, 2));
178
- } else {
179
- console.log(skillVersion);
180
- }
181
- return 0;
182
- }
183
-
184
- if (subcommand !== 'install') {
185
- throw new Error(`Unknown command: ${subcommand}. Run "xmemo-skill help" for usage.`);
186
- }
187
-
188
- const optionArgs = argv.slice(1);
189
- const options = parseInstallOptions(optionArgs);
190
- if (options.help) {
191
- printHelp();
192
- return 0;
193
- }
194
-
195
- const targetDir = options.target ?? env.XMEMO_SKILL_DIR ?? DEFAULT_INSTALL_DIR;
196
- const target = path.resolve(cwd, targetDir);
197
-
198
- assertSafeTarget(packageRoot, source, target);
199
-
200
- const exists = await pathExists(target);
201
- if (exists && !options.force) {
202
- throw new Error(`Skill destination already exists: ${target}. Use --force to replace it.`);
203
- }
204
-
205
- const report = {
206
- package: PACKAGE_NAME,
207
- skillVersion,
208
- source,
209
- target,
210
- dryRun: options.dryRun,
211
- force: options.force,
212
- replaced: exists && !options.dryRun,
213
- installed: false,
214
- networkUsed: false,
215
- tokenSent: false,
216
- };
217
-
218
- if (!options.dryRun) {
219
- await atomicInstall(source, target, exists);
220
- report.installed = true;
221
- }
222
-
223
- if (options.json) {
224
- console.log(JSON.stringify(report, null, 2));
225
- } else {
226
- console.log(`${options.dryRun ? 'Would install' : 'Installed'} XMemo Skill ${skillVersion} to ${target}`);
227
- console.log(`Source: ${PACKAGE_NAME} ${skillVersion} (offline; no credential used)`);
228
- }
229
-
230
- return 0;
231
- }
232
-
233
- function isDirectExecution() {
234
- if (!process.argv[1]) return false;
235
- const currentPath = fileURLToPath(import.meta.url);
236
- if (process.argv[1] === currentPath || path.resolve(process.argv[1]) === currentPath) {
237
- return true;
238
- }
239
- try {
240
- return realpathSync(process.argv[1]) === realpathSync(currentPath);
241
- } catch {
242
- return false;
243
- }
244
- }
245
-
246
- if (isDirectExecution()) {
247
- runCli().catch((err) => {
248
- console.error(err.message || String(err));
249
- process.exit(1);
250
- });
251
- }
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs/promises';
4
+ import { realpathSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { fileURLToPath } from 'node:url';
8
+ import process from 'node:process';
9
+
10
+ const PACKAGE_NAME = '@xmemo/skill';
11
+ const DEFAULT_INSTALL_DIR = 'xmemo-skill';
12
+
13
+ export async function locateSkillSource(metaUrl = import.meta.url) {
14
+ const currentDir = path.dirname(fileURLToPath(metaUrl));
15
+ const candidateStaged = path.resolve(currentDir, '..', 'skill');
16
+
17
+ const stagedStat = await fs.stat(candidateStaged).catch(() => null);
18
+ if (stagedStat?.isDirectory()) return candidateStaged;
19
+
20
+ throw new Error(`Could not locate bundled skill directory: ${candidateStaged}`);
21
+ }
22
+
23
+ export async function readSkillVersion(source) {
24
+ for (const required of ['SKILL.md', path.join('scripts', 'xmemo-skill.mjs')]) {
25
+ const stat = await fs.stat(path.join(source, required)).catch(() => null);
26
+ if (!stat?.isFile()) throw new Error(`Missing required Skill file: ${required}`);
27
+ }
28
+ const runtime = await fs.readFile(path.join(source, 'scripts', 'xmemo-skill.mjs'), 'utf8');
29
+ const match = runtime.match(/const SKILL_VERSION = '([^']+)';/);
30
+ if (!match?.[1]) throw new Error('The XMemo Skill version could not be determined from scripts/xmemo-skill.mjs.');
31
+ return match[1];
32
+ }
33
+
34
+ function resolveRealPath(p) {
35
+ try {
36
+ return realpathSync(p);
37
+ } catch {
38
+ const parent = path.dirname(p);
39
+ try {
40
+ return path.join(realpathSync(parent), path.basename(p));
41
+ } catch {
42
+ return path.resolve(p);
43
+ }
44
+ }
45
+ }
46
+
47
+ export function assertSafeTarget(packageRoot, source, target) {
48
+ if (target === path.parse(target).root) {
49
+ throw new Error('Refusing to install a Skill into a filesystem root.');
50
+ }
51
+ const realPkg = resolveRealPath(packageRoot);
52
+ const realSource = resolveRealPath(source);
53
+ const realTarget = resolveRealPath(target);
54
+
55
+ const relPkg = path.relative(realPkg, realTarget);
56
+ if (!relPkg || (!relPkg.startsWith('..') && !path.isAbsolute(relPkg))) {
57
+ throw new Error('Skill destination cannot be the package root or a directory inside it.');
58
+ }
59
+ const relSource = path.relative(realSource, realTarget);
60
+ if (!relSource || (!relSource.startsWith('..') && !path.isAbsolute(relSource))) {
61
+ throw new Error('Skill destination cannot be the skill source or a directory inside it.');
62
+ }
63
+ }
64
+
65
+ export async function pathExists(target) {
66
+ try {
67
+ await fs.access(target);
68
+ return true;
69
+ } catch (error) {
70
+ if (error?.code === 'ENOENT') return false;
71
+ throw error;
72
+ }
73
+ }
74
+
75
+ export async function atomicInstall(source, target, replace, { rename = fs.rename } = {}) {
76
+ const parent = path.dirname(target);
77
+ const base = path.basename(target);
78
+ const nonce = `${process.pid}-${randomUUID()}`;
79
+ const staging = path.join(parent, `.${base}.xmemo-staging-${nonce}`);
80
+ const backup = path.join(parent, `.${base}.xmemo-backup-${nonce}`);
81
+ let movedExisting = false;
82
+
83
+ await fs.mkdir(parent, { recursive: true });
84
+ try {
85
+ await fs.cp(source, staging, { recursive: true, errorOnExist: true, force: false });
86
+ if (replace) {
87
+ await rename(target, backup);
88
+ movedExisting = true;
89
+ }
90
+ await rename(staging, target);
91
+ if (movedExisting) {
92
+ await fs.rm(backup, { recursive: true, force: true });
93
+ movedExisting = false;
94
+ }
95
+ } catch (error) {
96
+ if (movedExisting && !(await pathExists(target))) {
97
+ await rename(backup, target).catch(() => {});
98
+ }
99
+ throw error;
100
+ } finally {
101
+ await fs.rm(staging, { recursive: true, force: true }).catch(() => {});
102
+ }
103
+ }
104
+
105
+ export function parseInstallOptions(args) {
106
+ const flags = new Set(['--dry-run', '--force', '--json']);
107
+ const seen = new Set();
108
+ let target = undefined;
109
+ let dryRun = false;
110
+ let force = false;
111
+ let json = false;
112
+
113
+ for (let i = 0; i < args.length; i += 1) {
114
+ const token = args[i];
115
+ if (token === '--help' || token === '-h') {
116
+ return { help: true };
117
+ }
118
+ if (token === '--target') {
119
+ if (seen.has(token)) throw new Error('Duplicate option: --target.');
120
+ const val = args[i + 1];
121
+ if (!val || val.startsWith('-')) throw new Error('Option --target requires a value.');
122
+ target = val;
123
+ seen.add(token);
124
+ i += 1;
125
+ continue;
126
+ }
127
+ if (!flags.has(token)) {
128
+ throw new Error(`Unknown skill install option: ${token}`);
129
+ }
130
+ if (seen.has(token)) {
131
+ throw new Error(`Duplicate option: ${token}.`);
132
+ }
133
+ seen.add(token);
134
+ if (token === '--dry-run') dryRun = true;
135
+ if (token === '--force') force = true;
136
+ if (token === '--json') json = true;
137
+ }
138
+
139
+ return { help: false, target, dryRun, force, json };
140
+ }
141
+
142
+ export function printHelp(stdout = console.log) {
143
+ stdout('XMemo Skill installer (@xmemo/skill)');
144
+ stdout('');
145
+ stdout('Usage:');
146
+ stdout(' xmemo-skill install [--target <dir>] [--dry-run] [--force] [--json]');
147
+ stdout(' xmemo-skill version [--json]');
148
+ stdout(' xmemo-skill help');
149
+ stdout('');
150
+ stdout('Commands:');
151
+ stdout(' install Install the XMemo Skill locally (offline, zero-network)');
152
+ stdout(' version Print the skill version');
153
+ stdout(' help Print this help message');
154
+ stdout('');
155
+ stdout('Options:');
156
+ stdout(' --target <dir> Installation directory (default: xmemo-skill, or $XMEMO_SKILL_DIR)');
157
+ stdout(' --dry-run Simulate installation without writing files');
158
+ stdout(' --force Overwrite destination directory if it already exists');
159
+ stdout(' --json Output results as JSON');
160
+ }
161
+
162
+ export async function runCli(argv = process.argv.slice(2), { cwd = process.cwd(), env = process.env, metaUrl = import.meta.url } = {}) {
163
+ const subcommand = argv[0] ?? 'help';
164
+
165
+ if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
166
+ printHelp();
167
+ return 0;
168
+ }
169
+
170
+ const source = await locateSkillSource(metaUrl);
171
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(metaUrl)), '..');
172
+ const skillVersion = await readSkillVersion(source);
173
+
174
+ if (subcommand === 'version' || subcommand === '--version' || subcommand === '-v') {
175
+ const isJson = argv.includes('--json');
176
+ if (isJson) {
177
+ console.log(JSON.stringify({ package: PACKAGE_NAME, version: skillVersion, skillVersion }, null, 2));
178
+ } else {
179
+ console.log(skillVersion);
180
+ }
181
+ return 0;
182
+ }
183
+
184
+ if (subcommand !== 'install') {
185
+ throw new Error(`Unknown command: ${subcommand}. Run "xmemo-skill help" for usage.`);
186
+ }
187
+
188
+ const optionArgs = argv.slice(1);
189
+ const options = parseInstallOptions(optionArgs);
190
+ if (options.help) {
191
+ printHelp();
192
+ return 0;
193
+ }
194
+
195
+ const targetDir = options.target ?? env.XMEMO_SKILL_DIR ?? DEFAULT_INSTALL_DIR;
196
+ const target = path.resolve(cwd, targetDir);
197
+
198
+ assertSafeTarget(packageRoot, source, target);
199
+
200
+ const exists = await pathExists(target);
201
+ if (exists && !options.force) {
202
+ throw new Error(`Skill destination already exists: ${target}. Use --force to replace it.`);
203
+ }
204
+
205
+ const report = {
206
+ package: PACKAGE_NAME,
207
+ skillVersion,
208
+ source,
209
+ target,
210
+ dryRun: options.dryRun,
211
+ force: options.force,
212
+ replaced: exists && !options.dryRun,
213
+ installed: false,
214
+ networkUsed: false,
215
+ tokenSent: false,
216
+ };
217
+
218
+ if (!options.dryRun) {
219
+ await atomicInstall(source, target, exists);
220
+ report.installed = true;
221
+ }
222
+
223
+ if (options.json) {
224
+ console.log(JSON.stringify(report, null, 2));
225
+ } else {
226
+ console.log(`${options.dryRun ? 'Would install' : 'Installed'} XMemo Skill ${skillVersion} to ${target}`);
227
+ console.log(`Source: ${PACKAGE_NAME} ${skillVersion} (offline; no credential used)`);
228
+ }
229
+
230
+ return 0;
231
+ }
232
+
233
+ function isDirectExecution() {
234
+ if (!process.argv[1]) return false;
235
+ const currentPath = fileURLToPath(import.meta.url);
236
+ if (process.argv[1] === currentPath || path.resolve(process.argv[1]) === currentPath) {
237
+ return true;
238
+ }
239
+ try {
240
+ return realpathSync(process.argv[1]) === realpathSync(currentPath);
241
+ } catch {
242
+ return false;
243
+ }
244
+ }
245
+
246
+ if (isDirectExecution()) {
247
+ runCli().catch((err) => {
248
+ console.error(err.message || String(err));
249
+ process.exit(1);
250
+ });
251
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmemo/skill",
3
- "version": "1.1.25",
3
+ "version": "1.1.26",
4
4
  "description": "Standalone installer and distribution package for the XMemo agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## 1.1.26
6
+
7
+ ### Fixed
8
+
9
+ - Classify HTTP 401 and 403 status codes using whole-number word boundaries, avoiding false-positive authentication error classifications on port numbers and identifiers.
10
+ - Clarified documentation for `ledger-list` in `references/ledger-operations.md` and `SKILL.md` to consistently describe it as a strictly read-only query command, explicitly noting that deleting or voiding a ledger entry is a separate operation requiring explicit confirmation (`forget --id <id> --confirm`) and a delete-capable scope (for example `memory:delete`; see the forget section for the full list).
11
+ - Structured reference links in `SKILL.md` as direct Markdown links with one-line descriptions.
12
+
13
+ ### Changed
14
+
15
+ - Modularized stdin and file input reading into a dedicated helper module (`scripts/lib/bounded-read.mjs`) with explicit streaming byte counting: capped at `MAX_MEMORY_CONTENT_BYTES` (512 KiB) for memory content and `MAX_STDIN_INPUT_BYTES` (64 KiB) for single-value stdin inputs (`auth add`), with early rejection on stream overflow.
16
+
5
17
  ## 1.1.25
6
18
 
7
19
  ### Added
package/skill/SKILL.md CHANGED
@@ -289,9 +289,11 @@ remain limited to `remember`, `recall`, and `search`.
289
289
  - `ledger-list` is a strictly read-only query backed by
290
290
  `POST /v1/skill/operations` (`operation: "ledger-list"`, requiring
291
291
  `ledger:read` scope). It retrieves financial and expense transactions without
292
- any write or delete capabilities. There is no separate `ledger-delete` command;
293
- to remove or void a transaction, obtain its `id` from `ledger-list` and invoke
294
- `forget --id <transaction_id> --confirm`. It accepts `--limit <n>`, `--offset <n>`,
292
+ any write or delete capabilities; `ledger-list` only reads and lists records.
293
+ Deleting or voiding a transaction is a separate operation that requires
294
+ explicit confirmation (`forget --id <id> --confirm`) and a delete-capable
295
+ scope (for example `memory:delete`; see the forget section for the full list).
296
+ It accepts `--limit <n>`, `--offset <n>`,
295
297
  `--currency <code>`, `--from <date>` (`date_from`), `--to <date>` (`date_to`),
296
298
  `--category <name>`, `--min-amount <n>`, `--max-amount <n>`, and `--type <type>`
297
299
  (`transaction_type`). As a convenience, `--month <YYYY-MM>` can be specified to
@@ -416,7 +418,11 @@ items, verify the credential scopes first. A valid `memory:read` token alone is
416
418
  not proof of Knowledge authorization; do not fall back to a broader token or
417
419
  attempt to inspect another user's Knowledge space.
418
420
 
419
- For memory and session workflows, read `references/memory-operations.md`. For ledger accounting and diagnostics, read `references/ledger-operations.md`. For command matrix, output formatting, and exit codes, read `references/runtime-operations.md`. For auth, network, and service diagnosis, read `references/troubleshooting.md`.
421
+ For detailed guides and operational references, see:
422
+ - [memory-operations.md](references/memory-operations.md): Core memory, knowledge context, and continuity workflows.
423
+ - [ledger-operations.md](references/ledger-operations.md): Ledger accounting, financial transactions, and account diagnostics.
424
+ - [runtime-operations.md](references/runtime-operations.md): Command matrix, output safety, JSON envelopes, and exit codes.
425
+ - [troubleshooting.md](references/troubleshooting.md): Auth, network, and service diagnosis and recovery.
420
426
 
421
427
  ## Exit Codes
422
428
 
@@ -28,7 +28,7 @@ node scripts/xmemo-skill.mjs ledger-list --month 2026-09 --json
28
28
  ```
29
29
 
30
30
  `ledger-list` queries personal financial transactions via `POST /v1/skill/operations` (`operation: "ledger-list"`, requiring `ledger:read` scope).
31
- This command is strictly read-only and possesses zero write or deletion capabilities. To delete or void a transaction, obtain its `id` from `ledger-list` and invoke `forget --id <transaction_id> --confirm`.
31
+ This command is strictly read-only and possesses zero write or deletion capabilities; `ledger-list` only reads and lists recorded transactions. Deleting or voiding a ledger entry is a separate operation that requires explicit confirmation (`forget --id <id> --confirm`) and a delete-capable scope (for example `memory:delete`; see the forget section for the full list).
32
32
  Allowed server arguments:
33
33
  - `--limit <n>`: Page limit (default 30, max 100).
34
34
  - `--offset <n>`: Pagination offset (default 0).
@@ -0,0 +1,162 @@
1
+ import fs from 'node:fs/promises';
2
+ import { MAX_MEMORY_CONTENT_BYTES, EXIT_CODE } from './core.mjs';
3
+ import { outputContentTooLarge } from './api.mjs';
4
+
5
+ // Maximum bounded payload for single-value stdin inputs: 65536 bytes (64 KiB).
6
+ export const MAX_STDIN_INPUT_BYTES = 65536;
7
+
8
+ /**
9
+ * Read stdin up to a bounded byte limit (default 64 KiB).
10
+ * Reading halts and rejects immediately if stdin exceeds maxBytes.
11
+ *
12
+ * @param {number} maxBytes - Maximum permitted bytes (default 65536)
13
+ * @returns {Promise<string>}
14
+ */
15
+ export function readStdin(maxBytes = MAX_STDIN_INPUT_BYTES) {
16
+ return new Promise((resolve, reject) => {
17
+ let totalBytes = 0;
18
+ const chunks = [];
19
+ let done = false;
20
+
21
+ const cleanup = () => {
22
+ process.stdin.removeListener('data', onData);
23
+ process.stdin.removeListener('end', onEnd);
24
+ process.stdin.removeListener('error', onError);
25
+ };
26
+
27
+ const onData = (chunk) => {
28
+ if (done) return;
29
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
30
+ totalBytes += buf.length;
31
+ // Explicit byte counter: stop and reject immediately when exceeding maxBytes
32
+ if (totalBytes > maxBytes) {
33
+ done = true;
34
+ cleanup();
35
+ try { process.stdin.pause(); } catch {}
36
+ const err = new Error(`Input exceeds maximum limit of ${maxBytes} bytes.`);
37
+ err.exitCode = EXIT_CODE.USER_ERROR;
38
+ reject(err);
39
+ return;
40
+ }
41
+ chunks.push(buf);
42
+ };
43
+
44
+ const onEnd = () => {
45
+ if (done) return;
46
+ cleanup();
47
+ resolve(Buffer.concat(chunks).toString('utf8').trim());
48
+ };
49
+
50
+ const onError = (err) => {
51
+ if (done) return;
52
+ cleanup();
53
+ reject(err);
54
+ };
55
+
56
+ process.stdin.on('data', onData).on('end', onEnd).on('error', onError).resume();
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Read memory content from stdin with explicit streaming byte counter.
62
+ * Reading halts and rejects immediately if stdin exceeds MAX_MEMORY_CONTENT_BYTES (524288 bytes).
63
+ *
64
+ * @param {object} options - Command options (json / terminal)
65
+ * @returns {Promise<string>}
66
+ */
67
+ export function readStdinContent(options = {}) {
68
+ return new Promise((resolve, reject) => {
69
+ // Explicit byte counter: bounded to MAX_MEMORY_CONTENT_BYTES (524288 bytes / 512 KiB)
70
+ let totalBytes = 0;
71
+ const chunks = [];
72
+ let done = false;
73
+
74
+ const cleanup = () => {
75
+ process.stdin.removeListener('data', onData);
76
+ process.stdin.removeListener('end', onEnd);
77
+ process.stdin.removeListener('error', onError);
78
+ };
79
+
80
+ const onData = (chunk) => {
81
+ if (done) return;
82
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
83
+ totalBytes += buf.length;
84
+ // Explicit byte counter check: stop and reject immediately if stream exceeds 524288 bytes
85
+ if (totalBytes > MAX_MEMORY_CONTENT_BYTES) {
86
+ done = true;
87
+ cleanup();
88
+ try { process.stdin.pause(); } catch {}
89
+ try { process.stdin.destroy(); } catch {}
90
+ outputContentTooLarge(`Memory content exceeds maximum limit of ${MAX_MEMORY_CONTENT_BYTES} bytes.`, options);
91
+ return;
92
+ }
93
+ chunks.push(buf);
94
+ };
95
+
96
+ const onEnd = () => {
97
+ if (done) return;
98
+ cleanup();
99
+ resolve(Buffer.concat(chunks).toString('utf8'));
100
+ };
101
+
102
+ const onError = (err) => {
103
+ if (done) return;
104
+ cleanup();
105
+ reject(err);
106
+ };
107
+
108
+ process.stdin.on('data', onData).on('end', onEnd).on('error', onError).resume();
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Read memory content from a regular file with bounded size verification.
114
+ * Rejects if file exceeds MAX_MEMORY_CONTENT_BYTES (524288 bytes / 512 KiB).
115
+ *
116
+ * @param {string} filePath - Path to file
117
+ * @param {object} options - Command options
118
+ * @returns {Promise<string|null>}
119
+ */
120
+ export async function readBoundedFile(filePath, options = {}) {
121
+ let stats;
122
+ try {
123
+ stats = await fs.stat(filePath);
124
+ } catch (err) {
125
+ throw new Error(`Failed to read file '${filePath}': ${err.message}`);
126
+ }
127
+ if (!stats.isFile()) {
128
+ throw new Error(`Failed to read file '${filePath}': --file must be a regular file.`);
129
+ }
130
+ if (stats.size > MAX_MEMORY_CONTENT_BYTES) {
131
+ outputContentTooLarge(`File '${filePath}' exceeds maximum limit of ${MAX_MEMORY_CONTENT_BYTES} bytes.`, options);
132
+ return null;
133
+ }
134
+ let handle;
135
+ try {
136
+ handle = await fs.open(filePath, 'r');
137
+ } catch (err) {
138
+ throw new Error(`Failed to read file '${filePath}': ${err.message}`);
139
+ }
140
+ const chunks = [];
141
+ let totalBytes = 0;
142
+ const chunkBuf = Buffer.alloc(65536);
143
+ try {
144
+ while (true) {
145
+ const toRead = Math.min(65536, (MAX_MEMORY_CONTENT_BYTES + 1) - totalBytes);
146
+ const { bytesRead } = await handle.read(chunkBuf, 0, toRead, null);
147
+ if (bytesRead === 0) break;
148
+ totalBytes += bytesRead;
149
+ chunks.push(Buffer.from(chunkBuf.subarray(0, bytesRead)));
150
+ if (totalBytes > MAX_MEMORY_CONTENT_BYTES) break;
151
+ }
152
+ } catch (err) {
153
+ throw new Error(`Failed to read file '${filePath}': ${err.message}`);
154
+ } finally {
155
+ await handle.close();
156
+ }
157
+ if (totalBytes > MAX_MEMORY_CONTENT_BYTES) {
158
+ outputContentTooLarge(`File '${filePath}' exceeds maximum limit of ${MAX_MEMORY_CONTENT_BYTES} bytes.`, options);
159
+ return null;
160
+ }
161
+ return Buffer.concat(chunks).toString('utf8');
162
+ }
@@ -1,9 +1,7 @@
1
- import fs from 'node:fs/promises';
2
1
  import {
3
2
  DEFAULT_BASE_URL,
4
3
  DEFAULT_TIMEOUT_MS,
5
4
  MAX_STATE_TTL_SECONDS,
6
- MAX_MEMORY_CONTENT_BYTES,
7
5
  COMMAND_FLAGS,
8
6
  AUTH_FLAGS,
9
7
  parseStrictBoolean,
@@ -11,7 +9,11 @@ import {
11
9
  parseIntegerInRange,
12
10
  parseJsonObject,
13
11
  } from './core.mjs';
14
- import { outputContentTooLarge } from './api.mjs';
12
+ import {
13
+ readStdin,
14
+ readStdinContent,
15
+ readBoundedFile,
16
+ } from './bounded-read.mjs';
15
17
 
16
18
  export function isStdoutTty() {
17
19
  const env = process.env;
@@ -185,98 +187,18 @@ export function validateCommandInput(command, subcommand, positionals, options,
185
187
  }
186
188
  }
187
189
 
188
- // Read stdin helper
189
- export async function readStdin() {
190
- return new Promise((resolve) => {
191
- let data = '';
192
- process.stdin.on('data', (chunk) => { data += chunk; });
193
- process.stdin.on('end', () => { resolve(data.trim()); });
194
- });
195
- }
196
-
197
- // Read full stdin content helper (exact UTF-8 content without trimming) with byte limit
198
- export function readStdinContent(options = {}) {
199
- return new Promise((resolve, reject) => {
200
- let totalBytes = 0;
201
- const chunks = [];
202
- let done = false;
203
- const cleanup = () => {
204
- process.stdin.removeListener('data', onData);
205
- process.stdin.removeListener('end', onEnd);
206
- process.stdin.removeListener('error', onError);
207
- };
208
- const onData = (chunk) => {
209
- if (done) return;
210
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
211
- totalBytes += buf.length;
212
- if (totalBytes > MAX_MEMORY_CONTENT_BYTES) {
213
- done = true;
214
- cleanup();
215
- try { process.stdin.pause(); } catch {}
216
- try { process.stdin.destroy(); } catch {}
217
- outputContentTooLarge(`Memory content exceeds maximum limit of ${MAX_MEMORY_CONTENT_BYTES} bytes.`, options);
218
- return;
219
- }
220
- chunks.push(buf);
221
- };
222
- const onEnd = () => {
223
- if (done) return;
224
- cleanup();
225
- resolve(Buffer.concat(chunks).toString('utf8'));
226
- };
227
- const onError = (err) => {
228
- if (done) return;
229
- cleanup();
230
- reject(err);
231
- };
232
- process.stdin.on('data', onData).on('end', onEnd).on('error', onError).resume();
233
- });
234
- }
190
+ export {
191
+ readStdin,
192
+ readStdinContent,
193
+ readBoundedFile,
194
+ };
235
195
 
236
196
  export async function resolveCommandInputs(command, flags, options = {}) {
237
197
  if (command === 'remember') {
238
198
  if (flags.file !== undefined) {
239
- let stats;
240
- try {
241
- stats = await fs.stat(flags.file);
242
- } catch (err) {
243
- throw new Error(`Failed to read file '${flags.file}': ${err.message}`);
244
- }
245
- if (!stats.isFile()) {
246
- throw new Error(`Failed to read file '${flags.file}': --file must be a regular file.`);
247
- }
248
- if (stats.size > MAX_MEMORY_CONTENT_BYTES) {
249
- outputContentTooLarge(`File '${flags.file}' exceeds maximum limit of ${MAX_MEMORY_CONTENT_BYTES} bytes.`, options);
250
- return;
251
- }
252
- let handle;
253
- try {
254
- handle = await fs.open(flags.file, 'r');
255
- } catch (err) {
256
- throw new Error(`Failed to read file '${flags.file}': ${err.message}`);
257
- }
258
- const chunks = [];
259
- let totalBytes = 0;
260
- const chunkBuf = Buffer.alloc(65536);
261
- try {
262
- while (true) {
263
- const toRead = Math.min(65536, (MAX_MEMORY_CONTENT_BYTES + 1) - totalBytes);
264
- const { bytesRead } = await handle.read(chunkBuf, 0, toRead, null);
265
- if (bytesRead === 0) break;
266
- totalBytes += bytesRead;
267
- chunks.push(Buffer.from(chunkBuf.subarray(0, bytesRead)));
268
- if (totalBytes > MAX_MEMORY_CONTENT_BYTES) break;
269
- }
270
- } catch (err) {
271
- throw new Error(`Failed to read file '${flags.file}': ${err.message}`);
272
- } finally {
273
- await handle.close();
274
- }
275
- if (totalBytes > MAX_MEMORY_CONTENT_BYTES) {
276
- outputContentTooLarge(`File '${flags.file}' exceeds maximum limit of ${MAX_MEMORY_CONTENT_BYTES} bytes.`, options);
277
- return;
278
- }
279
- flags.content = Buffer.concat(chunks).toString('utf8');
199
+ const content = await readBoundedFile(flags.file, options);
200
+ if (content === null) return;
201
+ flags.content = content;
280
202
  delete flags.file;
281
203
  } else if (flags.content === '-') {
282
204
  flags.content = await readStdinContent(options);
@@ -120,9 +120,8 @@ export function exitCodeForError(err) {
120
120
  return EXIT_CODE.SERVER_ERROR;
121
121
  }
122
122
  if (
123
- msg.includes('401') ||
123
+ /\b40[13]\b/.test(msg) ||
124
124
  msg.includes('unauthorized') ||
125
- msg.includes('403') ||
126
125
  msg.includes('forbidden') ||
127
126
  msg.includes('invalid or expired token') ||
128
127
  msg.includes('no xmemo credential found') ||
@@ -1,4 +1,4 @@
1
- import { DEFAULT_BASE_URL } from './core.mjs';
1
+ import { DEFAULT_BASE_URL, EXIT_CODE } from './core.mjs';
2
2
  import { isSurrogateToken, assertSurrogateOrigin } from './muse-vault.mjs';
3
3
 
4
4
  export const OPENCLAW_SENTINEL_REGEX = /^oc-sent-v2\.[A-Za-z0-9_-]+\.end$/;
@@ -32,16 +32,20 @@ export function assertOpenClawEgress(token, targetUrl, env = process.env) {
32
32
  if (!isOpenClawSentinel(token)) return;
33
33
 
34
34
  if (!isProxyEnvActive(env)) {
35
- throw new Error(
35
+ const error = new Error(
36
36
  'OpenClaw egress proxy is required when using OpenClaw secrets. Enable secrets.egressProxy.enabled and ensure execution runs in Gateway-hosted exec (HTTPS_PROXY and NODE_USE_ENV_PROXY=1 must be set).'
37
37
  );
38
+ error.exitCode = EXIT_CODE.USER_ERROR;
39
+ throw error;
38
40
  }
39
41
 
40
42
  const urlObj = typeof targetUrl === 'string' ? new URL(targetUrl) : targetUrl;
41
43
  if (urlObj.origin !== ALLOWED_EGRESS_ORIGIN) {
42
- throw new Error(
44
+ const error = new Error(
43
45
  `OpenClaw secret sentinels are restricted to ${ALLOWED_EGRESS_ORIGIN} and cannot be sent to ${urlObj.origin}. Unset XMEMO_BASE_URL or use a standard credential.`
44
46
  );
47
+ error.exitCode = EXIT_CODE.USER_ERROR;
48
+ throw error;
45
49
  }
46
50
  }
47
51
 
@@ -58,7 +58,7 @@ import { handleLedger } from './commands/ledger.mjs';
58
58
  import { handleAccount } from './commands/account.mjs';
59
59
  import { handleOps } from './commands/ops.mjs';
60
60
 
61
- const SKILL_VERSION = '1.1.25';
61
+ const SKILL_VERSION = '1.1.26';
62
62
 
63
63
  async function main() {
64
64
  let { command, subcommand, positionals, options, flags } = parseArgs(process.argv.slice(2));