@yeaft/webchat-agent 0.1.595 → 0.1.596

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.595",
3
+ "version": "0.1.596",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,89 @@
1
+ /**
2
+ * dream-v2/diff-gate.js — DESIGN.md §9.14.
3
+ *
4
+ * Hourly dream tick is cheap by default: read a per-scope cursor, check
5
+ * whether anything has changed since last pass, skip everything if not.
6
+ *
7
+ * The cursor is a tiny JSON file `<scopeDir>/.dream-cursor.json`:
8
+ *
9
+ * { "lastTickAt": "<ISO>", "lastSeenSig": "<opaque>" }
10
+ *
11
+ * `lastSeenSig` is whatever the caller wants to put there — typically a
12
+ * hash of the entries dir mtime + index.md mtime. The diff-gate doesn't
13
+ * compute the signature; it just compares the supplied "current" against
14
+ * the stored "last". That keeps signatures pluggable (mtime today, content
15
+ * hash later, ETag on a remote scope etc.).
16
+ */
17
+
18
+ import { promises as fs } from 'fs';
19
+ import { join, dirname } from 'path';
20
+
21
+ const FILE = '.dream-cursor.json';
22
+
23
+ /**
24
+ * @param {string} root
25
+ * @param {string} scopeDir
26
+ * @returns {string}
27
+ */
28
+ export function cursorPath(root, scopeDir) {
29
+ if (!root || !scopeDir) throw new Error('cursorPath: root + scopeDir required');
30
+ return join(root, scopeDir, FILE);
31
+ }
32
+
33
+ /**
34
+ * @param {{ root: string, scopeDir: string }} args
35
+ * @returns {Promise<{ lastTickAt: string|null, lastSeenSig: string|null }>}
36
+ */
37
+ export async function readCursor({ root, scopeDir }) {
38
+ const path = cursorPath(root, scopeDir);
39
+ try {
40
+ const content = await fs.readFile(path, 'utf8');
41
+ const parsed = JSON.parse(content);
42
+ return {
43
+ lastTickAt: typeof parsed?.lastTickAt === 'string' ? parsed.lastTickAt : null,
44
+ lastSeenSig: typeof parsed?.lastSeenSig === 'string' ? parsed.lastSeenSig : null,
45
+ };
46
+ } catch (err) {
47
+ if (err && err.code === 'ENOENT') return { lastTickAt: null, lastSeenSig: null };
48
+ if (err instanceof SyntaxError) return { lastTickAt: null, lastSeenSig: null };
49
+ throw err;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * @param {{
55
+ * root: string,
56
+ * scopeDir: string,
57
+ * sig: string,
58
+ * tickAt?: string,
59
+ * }} args
60
+ */
61
+ export async function writeCursor({ root, scopeDir, sig, tickAt }) {
62
+ if (typeof sig !== 'string') throw new Error('writeCursor: sig must be string');
63
+ const path = cursorPath(root, scopeDir);
64
+ await fs.mkdir(dirname(path), { recursive: true });
65
+ const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
66
+ await fs.writeFile(tmp, JSON.stringify({
67
+ lastTickAt: tickAt || new Date().toISOString(),
68
+ lastSeenSig: sig,
69
+ }), 'utf8');
70
+ await fs.rename(tmp, path);
71
+ }
72
+
73
+ /**
74
+ * Diff-gate decision. Pure: takes (last, current) and returns whether
75
+ * dream should run. Caller decides what `currentSig` means.
76
+ *
77
+ * @param {{ lastSeenSig: string|null }} last
78
+ * @param {string} currentSig
79
+ * @returns {{ skip: boolean, reason: string }}
80
+ */
81
+ export function shouldRunDream(last, currentSig) {
82
+ if (!last || last.lastSeenSig == null) {
83
+ return { skip: false, reason: 'no_cursor' };
84
+ }
85
+ if (last.lastSeenSig !== currentSig) {
86
+ return { skip: false, reason: 'diff' };
87
+ }
88
+ return { skip: true, reason: 'no_diff' };
89
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * dream-v2/scope-sig.js — DESIGN.md §9.14.
3
+ *
4
+ * Default signature for a scope dir: combine the mtimes of `entries/`,
5
+ * `index.md`, `summary.md` into a stable opaque string. Cheap and
6
+ * dependency-free — we explicitly stay out of "compute SHA over all
7
+ * entry bodies" territory because the dream tick is meant to be
8
+ * fingertip-cheap when nothing changed.
9
+ *
10
+ * Missing files contribute `0` to the signature, so cold-start scopes
11
+ * have a stable "empty" signature until the first entry lands.
12
+ */
13
+
14
+ import { promises as fs } from 'fs';
15
+ import { join } from 'path';
16
+
17
+ /**
18
+ * @param {{ root: string, scopeDir: string }} args
19
+ * @returns {Promise<string>}
20
+ */
21
+ export async function computeScopeSig({ root, scopeDir }) {
22
+ if (!root || !scopeDir) throw new Error('computeScopeSig: root + scopeDir required');
23
+ const targets = [
24
+ join(root, scopeDir, 'entries'),
25
+ join(root, scopeDir, 'index.md'),
26
+ join(root, scopeDir, 'summary.md'),
27
+ ];
28
+ const stamps = [];
29
+ for (const p of targets) {
30
+ try {
31
+ const s = await fs.stat(p);
32
+ stamps.push(`${s.mtimeMs.toFixed(0)}:${s.size}`);
33
+ } catch (err) {
34
+ if (err && err.code === 'ENOENT') {
35
+ stamps.push('0:0');
36
+ } else {
37
+ throw err;
38
+ }
39
+ }
40
+ }
41
+ return stamps.join('|');
42
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * dream-v2/tick.js — DESIGN.md §9.14 dream cadence.
3
+ *
4
+ * Hourly tick:
5
+ * 1. For each registered scope, compute current signature.
6
+ * 2. Read the scope cursor; if no diff, skip.
7
+ * 3. On diff (or `force`), call the supplied `refresh(scope)` hook,
8
+ * which is responsible for rewriting `summary.md` / `index.md`.
9
+ * Any errors per scope are captured; other scopes still run.
10
+ * 4. Write the new cursor.
11
+ *
12
+ * Phase 6 is intentionally refresh-only (DESIGN.md §8 line 395:
13
+ * "Thin: skip pruning/demotion; refresh-only in v1"). The `refresh`
14
+ * hook gets to decide what "refresh" means; this file just sequences
15
+ * the diff-gated calls.
16
+ */
17
+
18
+ import { computeScopeSig } from './scope-sig.js';
19
+ import { readCursor, writeCursor, shouldRunDream } from './diff-gate.js';
20
+
21
+ /**
22
+ * @typedef {{ kind: 'user'|'group'|'vp'|'task', id?: string, scopeDir: string }} ScopeRef
23
+ */
24
+
25
+ /**
26
+ * @param {{
27
+ * root: string,
28
+ * scopes: ScopeRef[],
29
+ * refresh: (scope: ScopeRef) => Promise<void>,
30
+ * force?: boolean,
31
+ * computeSig?: (scope: ScopeRef) => Promise<string>,
32
+ * now?: () => string,
33
+ * }} args
34
+ * @returns {Promise<{
35
+ * ran: Array<{ scopeDir: string, reason: string }>,
36
+ * skipped: Array<{ scopeDir: string, reason: string }>,
37
+ * errors: Array<{ scopeDir: string, error: Error }>,
38
+ * }>}
39
+ */
40
+ export async function runDreamTick({
41
+ root, scopes, refresh, force = false,
42
+ computeSig, now,
43
+ }) {
44
+ if (!root) throw new Error('runDreamTick: root required');
45
+ if (!Array.isArray(scopes)) throw new Error('runDreamTick: scopes array required');
46
+ if (typeof refresh !== 'function') throw new Error('runDreamTick: refresh fn required');
47
+
48
+ const sigOf = typeof computeSig === 'function'
49
+ ? computeSig
50
+ : (s) => computeScopeSig({ root, scopeDir: s.scopeDir });
51
+ const stamp = typeof now === 'function' ? now : () => new Date().toISOString();
52
+
53
+ const ran = [];
54
+ const skipped = [];
55
+ const errors = [];
56
+
57
+ for (const scope of scopes) {
58
+ if (!scope || !scope.scopeDir) continue;
59
+ let sig;
60
+ try {
61
+ sig = await sigOf(scope);
62
+ } catch (err) {
63
+ errors.push({ scopeDir: scope.scopeDir, error: err });
64
+ continue;
65
+ }
66
+ const last = await readCursor({ root, scopeDir: scope.scopeDir });
67
+ const decision = force
68
+ ? { skip: false, reason: 'forced' }
69
+ : shouldRunDream(last, sig);
70
+
71
+ if (decision.skip) {
72
+ skipped.push({ scopeDir: scope.scopeDir, reason: decision.reason });
73
+ continue;
74
+ }
75
+
76
+ try {
77
+ await refresh(scope);
78
+ ran.push({ scopeDir: scope.scopeDir, reason: decision.reason });
79
+ } catch (err) {
80
+ errors.push({ scopeDir: scope.scopeDir, error: err });
81
+ // Do NOT advance the cursor on failure — next tick should retry.
82
+ continue;
83
+ }
84
+
85
+ // Recompute the sig AFTER refresh in case the refresh hook itself
86
+ // wrote files; this is the value we want to compare against next tick.
87
+ let postSig;
88
+ try {
89
+ postSig = await sigOf(scope);
90
+ } catch {
91
+ postSig = sig;
92
+ }
93
+ await writeCursor({
94
+ root, scopeDir: scope.scopeDir, sig: postSig, tickAt: stamp(),
95
+ });
96
+ }
97
+
98
+ return { ran, skipped, errors };
99
+ }