@crouton-kit/crouter 0.3.41 → 0.3.42
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/dist/clients/attach/attach-cmd.js +156 -156
- package/dist/clients/attach/view-socket.d.ts +8 -1
- package/dist/clients/attach/view-socket.js +15 -1
- package/dist/commands/memory/shared.d.ts +7 -3
- package/dist/commands/memory/shared.js +35 -5
- package/dist/commands/memory/write.js +5 -3
- package/dist/core/profiles/select.d.ts +4 -2
- package/dist/core/profiles/select.js +30 -4
- package/package.json +1 -1
|
@@ -16,7 +16,8 @@ export type ReadOpRequest = Omit<ListModelsFrame, 'id'> | Omit<ListSessionsFrame
|
|
|
16
16
|
* broker on the same path. It gives up only when the node is genuinely gone:
|
|
17
17
|
* a terminal status (done/dead/canceled) or a reaped row (null). `idle` is NOT
|
|
18
18
|
* terminal — an idle-release node revives on its next inbox wake, so keep
|
|
19
|
-
* trying (the supervisor
|
|
19
|
+
* trying (the supervisor waits at a relaxed cadence for as long as the row
|
|
20
|
+
* stays live). */
|
|
20
21
|
export declare function reconnectShouldGiveUp(row: NodeRow | null): boolean;
|
|
21
22
|
export declare class BrokerUnavailableError extends Error {
|
|
22
23
|
readonly nodeId: string;
|
|
@@ -45,6 +46,12 @@ export declare class ViewSocketClient extends EventEmitter {
|
|
|
45
46
|
* Resolved by the matching `data` frame / rejected by the matching `error`
|
|
46
47
|
* frame in {@link onData}, the request's timeout, or socket teardown. */
|
|
47
48
|
private pending;
|
|
49
|
+
/** One redial fault line per reconnect EPISODE, not per attempt: the
|
|
50
|
+
* supervisor redials for as long as the node can come back (a dormant node
|
|
51
|
+
* may be gone for hours), so a per-attempt record would grow fault.log
|
|
52
|
+
* without bound. Set on the first failed redial, reset on any successful
|
|
53
|
+
* connect. */
|
|
54
|
+
private redialFaultRecorded;
|
|
48
55
|
constructor(nodeId: string);
|
|
49
56
|
private recordSocketFault;
|
|
50
57
|
/** Issue a correlated read-op and resolve with the broker's `data` reply (or
|
|
@@ -38,7 +38,8 @@ const REQUEST_TIMEOUT_MS = 10_000;
|
|
|
38
38
|
* broker on the same path. It gives up only when the node is genuinely gone:
|
|
39
39
|
* a terminal status (done/dead/canceled) or a reaped row (null). `idle` is NOT
|
|
40
40
|
* terminal — an idle-release node revives on its next inbox wake, so keep
|
|
41
|
-
* trying (the supervisor
|
|
41
|
+
* trying (the supervisor waits at a relaxed cadence for as long as the row
|
|
42
|
+
* stays live). */
|
|
42
43
|
export function reconnectShouldGiveUp(row) {
|
|
43
44
|
if (row === null)
|
|
44
45
|
return true;
|
|
@@ -61,11 +62,22 @@ export class ViewSocketClient extends EventEmitter {
|
|
|
61
62
|
* Resolved by the matching `data` frame / rejected by the matching `error`
|
|
62
63
|
* frame in {@link onData}, the request's timeout, or socket teardown. */
|
|
63
64
|
pending = new Map();
|
|
65
|
+
/** One redial fault line per reconnect EPISODE, not per attempt: the
|
|
66
|
+
* supervisor redials for as long as the node can come back (a dormant node
|
|
67
|
+
* may be gone for hours), so a per-attempt record would grow fault.log
|
|
68
|
+
* without bound. Set on the first failed redial, reset on any successful
|
|
69
|
+
* connect. */
|
|
70
|
+
redialFaultRecorded = false;
|
|
64
71
|
constructor(nodeId) {
|
|
65
72
|
super();
|
|
66
73
|
this.nodeId = nodeId;
|
|
67
74
|
}
|
|
68
75
|
recordSocketFault(op, err) {
|
|
76
|
+
if (op === 'redial') {
|
|
77
|
+
if (this.redialFaultRecorded)
|
|
78
|
+
return;
|
|
79
|
+
this.redialFaultRecorded = true;
|
|
80
|
+
}
|
|
69
81
|
const classified = classify('viewer↔broker', err);
|
|
70
82
|
recordFault(this.nodeId, {
|
|
71
83
|
link: 'viewer↔broker',
|
|
@@ -122,6 +134,7 @@ export class ViewSocketClient extends EventEmitter {
|
|
|
122
134
|
const socket = createConnection(this.socketPath);
|
|
123
135
|
this.socket = socket;
|
|
124
136
|
socket.on('connect', () => {
|
|
137
|
+
this.redialFaultRecorded = false;
|
|
125
138
|
clearFault(this.nodeId, { link: 'viewer↔broker' });
|
|
126
139
|
this.emit('connect');
|
|
127
140
|
});
|
|
@@ -148,6 +161,7 @@ export class ViewSocketClient extends EventEmitter {
|
|
|
148
161
|
if (settled)
|
|
149
162
|
return;
|
|
150
163
|
settled = true;
|
|
164
|
+
this.redialFaultRecorded = false;
|
|
151
165
|
clearFault(this.nodeId, { link: 'viewer↔broker' });
|
|
152
166
|
this.emit('connect');
|
|
153
167
|
resolve();
|
|
@@ -12,9 +12,13 @@ export declare function scopeRank(scope: MemoryScope): number;
|
|
|
12
12
|
* always resolves. `--scope profile` is NEVER a default — it requires a
|
|
13
13
|
* selected profile, from `profileArg` (an explicit `--profile <id-or-name>`)
|
|
14
14
|
* or else the process's `CRTR_PROFILE_ID`, resolved through the centralized
|
|
15
|
-
* `loadProfileManifest` (never a raw path join).
|
|
16
|
-
*
|
|
17
|
-
|
|
15
|
+
* `loadProfileManifest` (never a raw path join). `dirArg` (an explicit
|
|
16
|
+
* `--dir <path>`) pins the EXACT project directory, cwd-free: the target is
|
|
17
|
+
* `<dir>/.crouter/memory/`, scaffolded if absent, never the cwd-ancestor walk
|
|
18
|
+
* — the way a profiled agent writes into any project in its purview, and the
|
|
19
|
+
* only way to target a dir shadowed by an ancestor store. Returns the
|
|
20
|
+
* absolute `<root>/memory` dir to write under. */
|
|
21
|
+
export declare function resolveWriteTarget(scopeArg: string | undefined, profileArg?: string, dirArg?: string): {
|
|
18
22
|
scope: MemoryScope;
|
|
19
23
|
memoryDir: string;
|
|
20
24
|
};
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
// scope modules and build their documented output objects on top of the small
|
|
4
4
|
// helpers here. Nothing in this file forks on kind or re-implements the
|
|
5
5
|
// schema/gate/resolver — it only composes them.
|
|
6
|
-
import {
|
|
6
|
+
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
7
|
+
import { join, resolve as resolvePath } from 'node:path';
|
|
7
8
|
import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
|
|
8
9
|
import { usage } from '../../core/errors.js';
|
|
9
|
-
import {
|
|
10
|
+
import { CRTR_DIR_NAME } from '../../types.js';
|
|
11
|
+
import { scopeMemoryDir, projectScopeRoot, ensureProjectScopeRoot, resetScopeCache, } from '../../core/scope.js';
|
|
10
12
|
import { loadProfileManifest, profileMemoryDir } from '../../core/profiles/manifest.js';
|
|
11
13
|
// The two memory kinds — knowledge (consult: procedural playbooks + factual
|
|
12
14
|
// references merged) vs preference (behave: standing directives). Used as the
|
|
@@ -29,9 +31,37 @@ export function scopeRank(scope) {
|
|
|
29
31
|
* always resolves. `--scope profile` is NEVER a default — it requires a
|
|
30
32
|
* selected profile, from `profileArg` (an explicit `--profile <id-or-name>`)
|
|
31
33
|
* or else the process's `CRTR_PROFILE_ID`, resolved through the centralized
|
|
32
|
-
* `loadProfileManifest` (never a raw path join).
|
|
33
|
-
*
|
|
34
|
-
|
|
34
|
+
* `loadProfileManifest` (never a raw path join). `dirArg` (an explicit
|
|
35
|
+
* `--dir <path>`) pins the EXACT project directory, cwd-free: the target is
|
|
36
|
+
* `<dir>/.crouter/memory/`, scaffolded if absent, never the cwd-ancestor walk
|
|
37
|
+
* — the way a profiled agent writes into any project in its purview, and the
|
|
38
|
+
* only way to target a dir shadowed by an ancestor store. Returns the
|
|
39
|
+
* absolute `<root>/memory` dir to write under. */
|
|
40
|
+
export function resolveWriteTarget(scopeArg, profileArg, dirArg) {
|
|
41
|
+
if (dirArg !== undefined && dirArg !== '') {
|
|
42
|
+
if (scopeArg !== undefined && scopeArg !== 'project') {
|
|
43
|
+
throw usage(`--dir targets a project store and cannot combine with --scope ${scopeArg}`);
|
|
44
|
+
}
|
|
45
|
+
const abs = resolvePath(dirArg);
|
|
46
|
+
if (!existsSync(abs) || !statSync(abs).isDirectory()) {
|
|
47
|
+
throw usage(`--dir does not exist or is not a directory: ${dirArg}`, {
|
|
48
|
+
received: dirArg,
|
|
49
|
+
next: 'Pass an existing project directory.',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
let real = abs;
|
|
53
|
+
try {
|
|
54
|
+
real = realpathSync(abs);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
/* fall back to the resolved path */
|
|
58
|
+
}
|
|
59
|
+
// The scaffold (mkdir of `.crouter/memory/`) happens at write time via the
|
|
60
|
+
// writer's ensureDir; drop the scope cache so this process's later
|
|
61
|
+
// resolves see the new root.
|
|
62
|
+
resetScopeCache();
|
|
63
|
+
return { scope: 'project', memoryDir: join(real, CRTR_DIR_NAME, 'memory') };
|
|
64
|
+
}
|
|
35
65
|
let scope;
|
|
36
66
|
if (scopeArg === 'user' || scopeArg === 'project' || scopeArg === 'profile') {
|
|
37
67
|
scope = scopeArg;
|
|
@@ -13,7 +13,7 @@ export const writeLeaf = defineLeaf({
|
|
|
13
13
|
guide: 'The body is the easy part; the craft is routing — every frontmatter flag decides who sees this doc, when, and at what context cost. Each rung up is paid by every future agent at every boot or read, forever, so default each rung down.\n\n' +
|
|
14
14
|
'Pick the kind. knowledge is consulted for facts or procedures; preference directs behavior. The kind choice is about how the doc is used, not about how long it is.\n\n' +
|
|
15
15
|
'Set both visibility rungs explicitly on create. There is no kind default. `none`, `name`, `preview`, and `content` move from least to most loaded: `none` keeps the doc out of auto-load and on-read surfaces, `name` is the bare doc tag only, `preview` is the name + envelope + routing line (`when-and-why-to-read`), and `content` inlines the whole body when the body is short enough to justify it. Each axis is independent; usually one carries a real rung and the other is `none`. When a doc is short enough to state in a single sentence — a one-line preference, or a knowledge fact that fits in a sentence — skip `preview` and use `name` or `content` directly: the routing line would run longer than the doc itself, so a `preview` rung just adds words. Sentence-length `content` docs are not just acceptable, they are correct. Never pad a memory to be more verbose than the rule or fact it carries.\n\n' +
|
|
16
|
-
'Choose the hook — boot vs file-read. Behavior and how-to procedure surface at boot. Knowledge about code belongs next to the code: put the file in that directory’s .crouter/memory/ and it fires positionally when files there are read, costing nothing at boot. A knowledge doc about a person or process has no code directory to anchor to, so on-read triggering is meaningless — route it through boot instead and set the boot rung to `preview` when you want the routing line to surface.\n\n' +
|
|
16
|
+
'Choose the hook — boot vs file-read. Behavior and how-to procedure surface at boot. Knowledge about code belongs next to the code: put the file in that directory’s .crouter/memory/ (`--dir <project-dir>` targets it exactly, from anywhere) and it fires positionally when files there are read, costing nothing at boot. A knowledge doc about a person or process has no code directory to anchor to, so on-read triggering is meaningless — route it through boot instead and set the boot rung to `preview` when you want the routing line to surface.\n\n' +
|
|
17
17
|
'Write the routing line (--when-and-why-to-read) first, before storing anything: "When <circumstance>, this <kind> should be read because <payoff>." The test is whether a stranger mid-task can decide from that one line alone whether to spend the read. If you cannot name the concrete situation that triggers it, you do not yet understand the memory — ask the user one sharp question instead of improvising. Never paraphrase the advice in the routing line; keep it about when to read and why the read is worth it.\n\n' +
|
|
18
18
|
'Gate and read-when share the same predicate language: a field map is AND-ed across fields; dotted fields resolve nested values; field matchers may be scalar, array, or object. Scalar matchers do exact equality, with arrays matching any element. Array matchers do membership or intersection. Object matchers accept `eq`, `ne`, `in`, `nin`, `exists`, `contains`, `containsAll`, `containsAny`, `matches`, `imatches`, `gt`, `gte`, `lt`, and `lte`. Combinators are `all`, `any`, and `not`; sibling field matchers next to them are AND-ed in. An empty condition is inert. An unknown op never matches.\n\n' +
|
|
19
19
|
'Find before write. Group related docs with path names (area/topic). Do not store what is already recorded or what only matters to this conversation. Body is for current truth, not history. Provenance is automatic on create and preserved on update. Validate after authoring.',
|
|
@@ -27,7 +27,8 @@ export const writeLeaf = defineLeaf({
|
|
|
27
27
|
{ kind: 'flag', name: 'gate', type: 'string', required: false, constraint: 'Frontmatter gate — YAML/JSON object predicate over node config using the same field/matcher vocabulary described in the guide.' },
|
|
28
28
|
{ kind: 'flag', name: 'applies-to', type: 'string', required: false, constraint: 'Frontmatter applies-to — glob/path scope the document applies to.' },
|
|
29
29
|
{ kind: 'flag', name: 'read-when', type: 'string', required: false, constraint: 'Frontmatter read-when — YAML/JSON object predicate over a read file’s own frontmatter using the same field/matcher vocabulary described in the guide.' },
|
|
30
|
-
{ kind: 'flag', name: 'scope', type: 'enum', choices: [...MEMORY_SCOPES], required: false, constraint: 'Target scope. Default: project when inside a project, else user. `profile` requires a selected profile (CRTR_PROFILE_ID) or an explicit --profile.' },
|
|
30
|
+
{ kind: 'flag', name: 'scope', type: 'enum', choices: [...MEMORY_SCOPES], required: false, constraint: 'Target scope. Default: project when inside a project, else user. `project` resolves to the NEAREST ancestor `.crouter/` walking up from cwd — in a nested workspace that can be a parent’s store, not the dir you are standing in; pass --dir to pin the exact project directory. `profile` requires a selected profile (CRTR_PROFILE_ID) or an explicit --profile.' },
|
|
31
|
+
{ kind: 'flag', name: 'dir', type: 'string', required: false, constraint: 'Exact project directory to write under — targets `<dir>/.crouter/memory/` regardless of cwd or ancestor stores, scaffolding `.crouter/` there if absent. THE way to place a doc in a specific project’s store (e.g. another project in your profile’s purview) without cd’ing there, and the only way to target a dir shadowed by an ancestor store. Implies --scope project; rejects --scope user/profile.' },
|
|
31
32
|
{ kind: 'flag', name: 'profile', type: 'string', required: false, constraint: 'Profile id or name to write under, for --scope profile. Default: the process CRTR_PROFILE_ID (the node\u2019s selected profile). Resolved through the same profile lookup as `crtr profile show`; ignored for any other --scope.' },
|
|
32
33
|
{ kind: 'stdin', name: 'body', required: true, constraint: 'Document body (markdown, no frontmatter). Piped on stdin, or passed as the bare positional after <name>.' },
|
|
33
34
|
],
|
|
@@ -50,7 +51,8 @@ export const writeLeaf = defineLeaf({
|
|
|
50
51
|
const scopeArg = input['scope'];
|
|
51
52
|
const profileArg = input['profile'];
|
|
52
53
|
const body = input['body'] ?? '';
|
|
53
|
-
const
|
|
54
|
+
const dirArg = input['dir'];
|
|
55
|
+
const { scope, memoryDir } = resolveWriteTarget(scopeArg, profileArg, dirArg);
|
|
54
56
|
const path = memoryFilePath(memoryDir, name);
|
|
55
57
|
const created = !pathExists(path);
|
|
56
58
|
// CREATE requires the read-routing line that becomes the preview — without
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/** Select the profile a node about to boot at `cwd` should run under.
|
|
2
2
|
*
|
|
3
|
-
* 1. `explicitProfile` present → resolve id/name via `loadProfileManifest
|
|
4
|
-
*
|
|
3
|
+
* 1. `explicitProfile` present → resolve id/name via `loadProfileManifest`. If
|
|
4
|
+
* its manifest does not already cover `cwd` and the session is interactive,
|
|
5
|
+
* offer to add `cwd` to its purview (default yes). Bump `last_used_at`,
|
|
6
|
+
* return the id.
|
|
5
7
|
* 2. Else, among profiles whose project list COVERS `cwd`, pick the
|
|
6
8
|
* most-recently-used (nulls oldest), bump its `last_used_at`, return it.
|
|
7
9
|
* 3. Else — nothing covers cwd. Interactive session: prompt synchronously to
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { existsSync, realpathSync } from 'node:fs';
|
|
6
6
|
import { basename, resolve as resolvePath, sep } from 'node:path';
|
|
7
7
|
import { createInterface } from 'node:readline/promises';
|
|
8
|
-
import { listProfiles, loadProfileManifest, updateProfileLastUsed, createProfile, } from './manifest.js';
|
|
8
|
+
import { listProfiles, loadProfileManifest, updateProfileLastUsed, createProfile, addProfileProject, } from './manifest.js';
|
|
9
9
|
/** Resolve cwd to an absolute, realpath'd form so it compares against the
|
|
10
10
|
* realpath'd project dirs `createProfile`/`addProfileProject` already store
|
|
11
11
|
* (manifest.ts resolves every project dir through `realpathSync`). Falls back
|
|
@@ -66,6 +66,25 @@ function recoveryMessage(cwd) {
|
|
|
66
66
|
* artifact or a queued interaction, not a two-choice gate on the same TTY pi
|
|
67
67
|
* is about to take over — a raw `readline` question is the actual minimal
|
|
68
68
|
* fit here. */
|
|
69
|
+
/** Explicit `--profile` names a profile whose manifest does NOT cover cwd.
|
|
70
|
+
* Interactive only: offer to widen the profile's purview to include this
|
|
71
|
+
* directory (default yes), so the next node started here is covered without a
|
|
72
|
+
* separate `crtr profile add-project`. Declining leaves the manifest untouched
|
|
73
|
+
* and runs the node under the profile anyway. Same raw-readline rationale as
|
|
74
|
+
* `promptCreateOrRoot` — this gates pi's boot on the same TTY. */
|
|
75
|
+
async function promptAddDirToProfile(entry, cwd) {
|
|
76
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
77
|
+
try {
|
|
78
|
+
process.stdout.write(`\nProfile "${entry.manifest.name}" does not cover this directory:\n ${cwd}\n\n`);
|
|
79
|
+
const choice = (await rl.question('Add it to the profile? [Y/n]: ')).trim().toLowerCase();
|
|
80
|
+
if (choice === 'n' || choice === 'no')
|
|
81
|
+
return;
|
|
82
|
+
addProfileProject(entry.profileId, cwd);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
rl.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
69
88
|
async function promptCreateOrRoot(cwd) {
|
|
70
89
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
71
90
|
try {
|
|
@@ -86,8 +105,10 @@ async function promptCreateOrRoot(cwd) {
|
|
|
86
105
|
}
|
|
87
106
|
/** Select the profile a node about to boot at `cwd` should run under.
|
|
88
107
|
*
|
|
89
|
-
* 1. `explicitProfile` present → resolve id/name via `loadProfileManifest
|
|
90
|
-
*
|
|
108
|
+
* 1. `explicitProfile` present → resolve id/name via `loadProfileManifest`. If
|
|
109
|
+
* its manifest does not already cover `cwd` and the session is interactive,
|
|
110
|
+
* offer to add `cwd` to its purview (default yes). Bump `last_used_at`,
|
|
111
|
+
* return the id.
|
|
91
112
|
* 2. Else, among profiles whose project list COVERS `cwd`, pick the
|
|
92
113
|
* most-recently-used (nulls oldest), bump its `last_used_at`, return it.
|
|
93
114
|
* 3. Else — nothing covers cwd. Interactive session: prompt synchronously to
|
|
@@ -95,12 +116,17 @@ async function promptCreateOrRoot(cwd) {
|
|
|
95
116
|
* interactive (no TTY): default to root (null) and print the recovery
|
|
96
117
|
* instruction to STDERR — never stdout, which the caller may be piping. */
|
|
97
118
|
export async function selectProfileForCwd(cwd, explicitProfile) {
|
|
119
|
+
const resolvedCwd = resolveCwd(cwd);
|
|
98
120
|
if (explicitProfile !== undefined && explicitProfile !== null && explicitProfile !== '') {
|
|
99
121
|
const entry = loadProfileManifest(explicitProfile);
|
|
122
|
+
// Selecting a profile from a directory it does not yet cover: offer to
|
|
123
|
+
// widen its purview (interactive only, default yes) before pi boots.
|
|
124
|
+
if (!profileCoversCwd(entry, resolvedCwd) && isInteractive()) {
|
|
125
|
+
await promptAddDirToProfile(entry, resolvedCwd);
|
|
126
|
+
}
|
|
100
127
|
updateProfileLastUsed(entry.profileId);
|
|
101
128
|
return entry.profileId;
|
|
102
129
|
}
|
|
103
|
-
const resolvedCwd = resolveCwd(cwd);
|
|
104
130
|
const covering = listProfiles().filter((p) => profileCoversCwd(p, resolvedCwd));
|
|
105
131
|
if (covering.length > 0) {
|
|
106
132
|
const winner = pickMostRecent(covering);
|