sentinelayer-cli 0.8.4 → 0.8.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 +1 -1
- package/src/commands/session.js +279 -8
- package/src/session/live-source.js +308 -0
- package/src/session/preview.js +91 -0
- package/src/session/remote-hydrate.js +95 -0
- package/src/session/store.js +46 -0
- package/src/session/sync-cursor.js +62 -0
package/package.json
CHANGED
package/src/commands/session.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fsp from "node:fs/promises";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
@@ -44,10 +45,13 @@ import {
|
|
|
44
45
|
DEFAULT_TTL_SECONDS,
|
|
45
46
|
getSession,
|
|
46
47
|
listActiveSessions,
|
|
48
|
+
listAllSessions,
|
|
47
49
|
recordSessionProvisionedIdentities,
|
|
48
50
|
} from "../session/store.js";
|
|
49
51
|
import { appendToStream, readStream, tailStream } from "../session/stream.js";
|
|
52
|
+
import { readSessionPreview } from "../session/preview.js";
|
|
50
53
|
import { syncSessionMetadataToApi } from "../session/sync.js";
|
|
54
|
+
import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
|
|
51
55
|
import {
|
|
52
56
|
buildDashboardUrl,
|
|
53
57
|
buildTemplateLaunchPlan,
|
|
@@ -450,6 +454,10 @@ export function registerSessionCommand(program) {
|
|
|
450
454
|
.description("Read recent session messages")
|
|
451
455
|
.option("--tail <n>", "Number of recent events", "20")
|
|
452
456
|
.option("--follow", "Continuously follow new events")
|
|
457
|
+
.option(
|
|
458
|
+
"--remote",
|
|
459
|
+
"Hydrate from the SentinelLayer API before reading (pulls web-posted messages into the local NDJSON)",
|
|
460
|
+
)
|
|
453
461
|
.option("--path <path>", "Workspace path for the session", ".")
|
|
454
462
|
.option("--json", "Emit machine-readable output")
|
|
455
463
|
.action(async (sessionId, options, command) => {
|
|
@@ -461,6 +469,29 @@ export function registerSessionCommand(program) {
|
|
|
461
469
|
const tail = parsePositiveInteger(options.tail, "tail", 20);
|
|
462
470
|
const emitJson = shouldEmitJson(options, command);
|
|
463
471
|
|
|
472
|
+
let hydration = null;
|
|
473
|
+
if (options.remote) {
|
|
474
|
+
hydration = await hydrateSessionFromRemote({
|
|
475
|
+
sessionId: normalizedSessionId,
|
|
476
|
+
targetPath,
|
|
477
|
+
});
|
|
478
|
+
if (!emitJson) {
|
|
479
|
+
if (hydration.ok) {
|
|
480
|
+
console.log(
|
|
481
|
+
pc.gray(
|
|
482
|
+
`Hydrated from remote: relayed=${hydration.relayed} dropped=${hydration.dropped}.`,
|
|
483
|
+
),
|
|
484
|
+
);
|
|
485
|
+
} else {
|
|
486
|
+
console.log(
|
|
487
|
+
pc.yellow(
|
|
488
|
+
`Remote hydrate skipped (${hydration.reason}); showing local stream only.`,
|
|
489
|
+
),
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
464
495
|
if (!options.follow) {
|
|
465
496
|
const events = await readStream(normalizedSessionId, {
|
|
466
497
|
targetPath,
|
|
@@ -473,6 +504,7 @@ export function registerSessionCommand(program) {
|
|
|
473
504
|
tail,
|
|
474
505
|
count: events.length,
|
|
475
506
|
events,
|
|
507
|
+
remote: hydration,
|
|
476
508
|
};
|
|
477
509
|
if (emitJson) {
|
|
478
510
|
console.log(JSON.stringify(payload, null, 2));
|
|
@@ -499,6 +531,59 @@ export function registerSessionCommand(program) {
|
|
|
499
531
|
}
|
|
500
532
|
});
|
|
501
533
|
|
|
534
|
+
session
|
|
535
|
+
.command("sync <sessionId>")
|
|
536
|
+
.description(
|
|
537
|
+
"Pull human messages from the SentinelLayer API into the local NDJSON stream",
|
|
538
|
+
)
|
|
539
|
+
.option(
|
|
540
|
+
"--since <iso>",
|
|
541
|
+
"Override the persisted cursor and start from this ISO timestamp",
|
|
542
|
+
)
|
|
543
|
+
.option("--path <path>", "Workspace path for the session", ".")
|
|
544
|
+
.option("--json", "Emit machine-readable output")
|
|
545
|
+
.action(async (sessionId, options, command) => {
|
|
546
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
547
|
+
if (!normalizedSessionId) {
|
|
548
|
+
throw new Error("session id is required.");
|
|
549
|
+
}
|
|
550
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
551
|
+
const sinceArg = options.since == null ? undefined : String(options.since);
|
|
552
|
+
|
|
553
|
+
const result = await hydrateSessionFromRemote({
|
|
554
|
+
sessionId: normalizedSessionId,
|
|
555
|
+
targetPath,
|
|
556
|
+
since: sinceArg,
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
const payload = {
|
|
560
|
+
command: "session sync",
|
|
561
|
+
targetPath,
|
|
562
|
+
sessionId: normalizedSessionId,
|
|
563
|
+
ok: result.ok,
|
|
564
|
+
reason: result.reason || "",
|
|
565
|
+
relayed: result.relayed,
|
|
566
|
+
dropped: result.dropped,
|
|
567
|
+
cursor: result.cursor,
|
|
568
|
+
persistedCursor: result.persistedCursor,
|
|
569
|
+
};
|
|
570
|
+
if (shouldEmitJson(options, command)) {
|
|
571
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (result.ok) {
|
|
575
|
+
console.log(
|
|
576
|
+
`Hydrated session ${normalizedSessionId}: relayed=${result.relayed} dropped=${result.dropped}.`,
|
|
577
|
+
);
|
|
578
|
+
} else {
|
|
579
|
+
console.log(
|
|
580
|
+
pc.yellow(
|
|
581
|
+
`Hydrate skipped (${result.reason}). Local stream is unchanged; cursor=${result.cursor || "<none>"}.`,
|
|
582
|
+
),
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
502
587
|
session
|
|
503
588
|
.command("status <sessionId>")
|
|
504
589
|
.description("Show session status, agents, and health")
|
|
@@ -581,6 +666,94 @@ export function registerSessionCommand(program) {
|
|
|
581
666
|
}
|
|
582
667
|
});
|
|
583
668
|
|
|
669
|
+
session
|
|
670
|
+
.command("export <sessionId>")
|
|
671
|
+
.description(
|
|
672
|
+
"Export full transcript + metadata + agents + tasks as JSON (compliance / portability / context handoff)",
|
|
673
|
+
)
|
|
674
|
+
.option(
|
|
675
|
+
"--format <fmt>",
|
|
676
|
+
"Output format: json (single object) or ndjson (one event per line)",
|
|
677
|
+
"json",
|
|
678
|
+
)
|
|
679
|
+
.option("--out <file>", "Write to file instead of stdout")
|
|
680
|
+
.option("--path <path>", "Workspace path for the session", ".")
|
|
681
|
+
.action(async (sessionId, options) => {
|
|
682
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
683
|
+
if (!normalizedSessionId) {
|
|
684
|
+
throw new Error("session id is required.");
|
|
685
|
+
}
|
|
686
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
687
|
+
const format = String(options.format || "json").trim().toLowerCase();
|
|
688
|
+
if (format !== "json" && format !== "ndjson") {
|
|
689
|
+
throw new Error(`--format must be 'json' or 'ndjson' (received '${format}').`);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const sessionPayload = await getSession(normalizedSessionId, { targetPath });
|
|
693
|
+
if (!sessionPayload) {
|
|
694
|
+
throw new Error(`Session '${normalizedSessionId}' was not found.`);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const [agents, events, tasks] = await Promise.all([
|
|
698
|
+
listAgents(normalizedSessionId, {
|
|
699
|
+
targetPath,
|
|
700
|
+
includeInactive: true,
|
|
701
|
+
}),
|
|
702
|
+
readStream(normalizedSessionId, {
|
|
703
|
+
targetPath,
|
|
704
|
+
tail: 0,
|
|
705
|
+
}),
|
|
706
|
+
listSessionTasks(normalizedSessionId, {
|
|
707
|
+
targetPath,
|
|
708
|
+
limit: 5_000,
|
|
709
|
+
}),
|
|
710
|
+
]);
|
|
711
|
+
|
|
712
|
+
let output;
|
|
713
|
+
if (format === "ndjson") {
|
|
714
|
+
const lines = [];
|
|
715
|
+
lines.push(JSON.stringify({ kind: "session", value: sessionPayload }));
|
|
716
|
+
for (const agent of agents) lines.push(JSON.stringify({ kind: "agent", value: agent }));
|
|
717
|
+
for (const event of events) lines.push(JSON.stringify({ kind: "event", value: event }));
|
|
718
|
+
for (const task of tasks.tasks || []) lines.push(JSON.stringify({ kind: "task", value: task }));
|
|
719
|
+
output = `${lines.join("\n")}\n`;
|
|
720
|
+
} else {
|
|
721
|
+
output = `${JSON.stringify(
|
|
722
|
+
{
|
|
723
|
+
command: "session export",
|
|
724
|
+
exportedAt: new Date().toISOString(),
|
|
725
|
+
session: sessionPayload,
|
|
726
|
+
agents,
|
|
727
|
+
events,
|
|
728
|
+
tasks: tasks.tasks || [],
|
|
729
|
+
counts: {
|
|
730
|
+
agents: agents.length,
|
|
731
|
+
events: events.length,
|
|
732
|
+
tasks: (tasks.tasks || []).length,
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
null,
|
|
736
|
+
2,
|
|
737
|
+
)}\n`;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const outArg = normalizeString(options.out);
|
|
741
|
+
if (outArg) {
|
|
742
|
+
const outPath = path.resolve(process.cwd(), outArg);
|
|
743
|
+
await fsp.mkdir(path.dirname(outPath), { recursive: true });
|
|
744
|
+
await fsp.writeFile(outPath, output, "utf-8");
|
|
745
|
+
console.log(
|
|
746
|
+
pc.gray(
|
|
747
|
+
`Exported ${events.length} events / ${agents.length} agents / ${
|
|
748
|
+
(tasks.tasks || []).length
|
|
749
|
+
} tasks → ${outPath}`,
|
|
750
|
+
),
|
|
751
|
+
);
|
|
752
|
+
} else {
|
|
753
|
+
process.stdout.write(output);
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
|
|
584
757
|
session
|
|
585
758
|
.command("leave <sessionId>")
|
|
586
759
|
.description("Leave a session")
|
|
@@ -617,31 +790,129 @@ export function registerSessionCommand(program) {
|
|
|
617
790
|
|
|
618
791
|
session
|
|
619
792
|
.command("list")
|
|
620
|
-
.description("List
|
|
793
|
+
.description("List sessions in the local workspace cache")
|
|
794
|
+
.option(
|
|
795
|
+
"--include-archived",
|
|
796
|
+
"Include archived/expired sessions (past conversations)",
|
|
797
|
+
)
|
|
798
|
+
.option(
|
|
799
|
+
"--limit <n>",
|
|
800
|
+
"Maximum sessions to return (default 50; ignored on --json)",
|
|
801
|
+
"50",
|
|
802
|
+
)
|
|
621
803
|
.option("--path <path>", "Workspace path for sessions", ".")
|
|
622
804
|
.option("--json", "Emit machine-readable output")
|
|
623
805
|
.action(async (options, command) => {
|
|
624
806
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
625
|
-
const
|
|
626
|
-
|
|
627
|
-
|
|
807
|
+
const includeArchived = Boolean(options.includeArchived);
|
|
808
|
+
const limit = parsePositiveInteger(options.limit, "limit", 50);
|
|
809
|
+
const sessions = includeArchived
|
|
810
|
+
? await listAllSessions({ targetPath })
|
|
811
|
+
: await listActiveSessions({ targetPath });
|
|
812
|
+
const trimmed = shouldEmitJson(options, command) ? sessions : sessions.slice(0, limit);
|
|
628
813
|
const payload = {
|
|
629
814
|
command: "session list",
|
|
630
815
|
targetPath,
|
|
816
|
+
includeArchived,
|
|
631
817
|
count: sessions.length,
|
|
632
|
-
sessions,
|
|
818
|
+
sessions: trimmed,
|
|
633
819
|
};
|
|
634
820
|
if (shouldEmitJson(options, command)) {
|
|
635
821
|
console.log(JSON.stringify(payload, null, 2));
|
|
636
822
|
return;
|
|
637
823
|
}
|
|
638
824
|
if (sessions.length === 0) {
|
|
639
|
-
console.log(
|
|
825
|
+
console.log(
|
|
826
|
+
pc.yellow(
|
|
827
|
+
includeArchived
|
|
828
|
+
? "No sessions in cache."
|
|
829
|
+
: "No active sessions. Run with --include-archived to see history.",
|
|
830
|
+
),
|
|
831
|
+
);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
for (const item of trimmed) {
|
|
835
|
+
const archive = item.archiveStatus ? ` archive=${item.archiveStatus}` : "";
|
|
836
|
+
console.log(
|
|
837
|
+
`${item.sessionId} status=${item.status}${archive} created=${item.createdAt} expires=${item.expiresAt}`,
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
if (sessions.length > trimmed.length) {
|
|
841
|
+
console.log(
|
|
842
|
+
pc.gray(
|
|
843
|
+
`… ${sessions.length - trimmed.length} more (raise --limit or use --json).`,
|
|
844
|
+
),
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
session
|
|
850
|
+
.command("history")
|
|
851
|
+
.description(
|
|
852
|
+
"Past conversations with a one-line preview of the most recent message (alias for `session list --include-archived` + previews)",
|
|
853
|
+
)
|
|
854
|
+
.option("--limit <n>", "Maximum sessions to return", "50")
|
|
855
|
+
.option("--no-preview", "Skip the per-session preview lookup")
|
|
856
|
+
.option("--path <path>", "Workspace path for sessions", ".")
|
|
857
|
+
.option("--json", "Emit machine-readable output")
|
|
858
|
+
.action(async (options, command) => {
|
|
859
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
860
|
+
const limit = parsePositiveInteger(options.limit, "limit", 50);
|
|
861
|
+
const wantPreview = options.preview !== false;
|
|
862
|
+
const sessions = await listAllSessions({ targetPath });
|
|
863
|
+
const trimmed = shouldEmitJson(options, command) ? sessions : sessions.slice(0, limit);
|
|
864
|
+
|
|
865
|
+
let previews = new Map();
|
|
866
|
+
if (wantPreview && trimmed.length > 0) {
|
|
867
|
+
const entries = await Promise.all(
|
|
868
|
+
trimmed.map(async (item) => [
|
|
869
|
+
item.sessionId,
|
|
870
|
+
await readSessionPreview(item.sessionId, { targetPath }),
|
|
871
|
+
]),
|
|
872
|
+
);
|
|
873
|
+
previews = new Map(entries);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (shouldEmitJson(options, command)) {
|
|
877
|
+
const payload = {
|
|
878
|
+
command: "session history",
|
|
879
|
+
targetPath,
|
|
880
|
+
count: sessions.length,
|
|
881
|
+
sessions: trimmed.map((item) => ({
|
|
882
|
+
...item,
|
|
883
|
+
preview: previews.get(item.sessionId) || null,
|
|
884
|
+
})),
|
|
885
|
+
};
|
|
886
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
if (sessions.length === 0) {
|
|
891
|
+
console.log(pc.yellow("No sessions in cache."));
|
|
640
892
|
return;
|
|
641
893
|
}
|
|
642
|
-
for (const item of
|
|
894
|
+
for (const item of trimmed) {
|
|
895
|
+
const archive = item.archiveStatus.padEnd(8);
|
|
896
|
+
const head =
|
|
897
|
+
`${archive} ${item.sessionId} created=${item.createdAt}` +
|
|
898
|
+
(item.archivedAt ? ` archived=${item.archivedAt}` : "");
|
|
899
|
+
if (!wantPreview) {
|
|
900
|
+
console.log(head);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
const preview = previews.get(item.sessionId);
|
|
904
|
+
if (preview && preview.message) {
|
|
905
|
+
const speaker = preview.agentId ? `${preview.agentId}: ` : "";
|
|
906
|
+
console.log(`${head}\n ${pc.gray(`${speaker}${preview.message}`)}`);
|
|
907
|
+
} else {
|
|
908
|
+
console.log(`${head}\n ${pc.gray("(no messages yet)")}`);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
if (sessions.length > trimmed.length) {
|
|
643
912
|
console.log(
|
|
644
|
-
|
|
913
|
+
pc.gray(
|
|
914
|
+
`… ${sessions.length - trimmed.length} more (raise --limit or use --json).`,
|
|
915
|
+
),
|
|
645
916
|
);
|
|
646
917
|
}
|
|
647
918
|
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live session-event source — composes `fs.watch` (instant local notify
|
|
3
|
+
* when the NDJSON file changes) and SSE (`/api/v1/sessions/<id>/stream`,
|
|
4
|
+
* server-pushed updates) into a single async iterator.
|
|
5
|
+
*
|
|
6
|
+
* The two lanes give us the WebRTC-like behavior the user asked about
|
|
7
|
+
* without the WebRTC operational tax: same-machine peers see each
|
|
8
|
+
* other's writes through `fs.watch` immediately; remote peers receive
|
|
9
|
+
* via SSE the moment the API persists. A single stream emits both, with
|
|
10
|
+
* dedup by event id so the same event seen on both lanes only surfaces
|
|
11
|
+
* once.
|
|
12
|
+
*
|
|
13
|
+
* Tests inject `_watch`, `_sse`, and `_readEvents` so the iterator can
|
|
14
|
+
* be exercised hermetically; production uses `node:fs` watch + native
|
|
15
|
+
* fetch streaming.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
20
|
+
|
|
21
|
+
import { resolveSessionPaths } from "./paths.js";
|
|
22
|
+
import { readStream } from "./stream.js";
|
|
23
|
+
|
|
24
|
+
const DEFAULT_RECONNECT_BACKOFF_MS = 2_000;
|
|
25
|
+
const MAX_RECONNECT_BACKOFF_MS = 30_000;
|
|
26
|
+
|
|
27
|
+
function eventKey(event) {
|
|
28
|
+
if (!event || typeof event !== "object") return null;
|
|
29
|
+
if (event.id) return `id:${event.id}`;
|
|
30
|
+
if (event.eventId) return `id:${event.eventId}`;
|
|
31
|
+
const ts = event.ts || event.timestamp;
|
|
32
|
+
const kind = event.event || event.type;
|
|
33
|
+
if (ts && kind) return `${ts}::${kind}`;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Watch a session's NDJSON file with `fs.watch`. Whenever the file
|
|
39
|
+
* changes (append-only writes happen on every event), re-read the tail
|
|
40
|
+
* and emit any events the consumer hasn't seen yet. Falls back to a
|
|
41
|
+
* 500 ms poll on platforms where `fs.watch` is unreliable (some
|
|
42
|
+
* Windows + network mounts) — controlled by `_watch` for tests.
|
|
43
|
+
*
|
|
44
|
+
* Async generator yields `{ source: "fs", event }`.
|
|
45
|
+
*/
|
|
46
|
+
export async function* watchLocalStream({
|
|
47
|
+
sessionId,
|
|
48
|
+
targetPath,
|
|
49
|
+
signal,
|
|
50
|
+
initialTail = 50,
|
|
51
|
+
_watch = fs.watch,
|
|
52
|
+
_readEvents = readStream,
|
|
53
|
+
} = {}) {
|
|
54
|
+
if (!sessionId) return;
|
|
55
|
+
const paths = resolveSessionPaths(sessionId, { targetPath });
|
|
56
|
+
let lastTs = null;
|
|
57
|
+
|
|
58
|
+
// Replay the tail first so any caller getting the iterator catches
|
|
59
|
+
// up with the in-flight context before live events start arriving.
|
|
60
|
+
const initial = await _readEvents(sessionId, { targetPath, tail: initialTail });
|
|
61
|
+
for (const event of initial) {
|
|
62
|
+
const candidate = event.ts || event.timestamp;
|
|
63
|
+
if (candidate) lastTs = candidate;
|
|
64
|
+
yield { source: "fs", event };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let pendingResolve = null;
|
|
68
|
+
let pendingPromise = null;
|
|
69
|
+
const queue = [];
|
|
70
|
+
|
|
71
|
+
function notify() {
|
|
72
|
+
if (pendingResolve) {
|
|
73
|
+
const r = pendingResolve;
|
|
74
|
+
pendingResolve = null;
|
|
75
|
+
pendingPromise = null;
|
|
76
|
+
r();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let watcher = null;
|
|
81
|
+
try {
|
|
82
|
+
watcher = _watch(paths.streamPath, { persistent: false }, () => notify());
|
|
83
|
+
} catch {
|
|
84
|
+
// If watch can't attach (file missing yet, locked filesystem), we
|
|
85
|
+
// fall back to a 500ms poll so the iterator still makes progress.
|
|
86
|
+
watcher = {
|
|
87
|
+
close() {},
|
|
88
|
+
};
|
|
89
|
+
(async () => {
|
|
90
|
+
while (!signal?.aborted) {
|
|
91
|
+
await sleep(500);
|
|
92
|
+
notify();
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const aborted = () => Boolean(signal?.aborted);
|
|
98
|
+
if (signal) signal.addEventListener("abort", () => notify(), { once: true });
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
while (!aborted()) {
|
|
102
|
+
// Wait for any change notification.
|
|
103
|
+
pendingPromise = new Promise((resolve) => {
|
|
104
|
+
pendingResolve = resolve;
|
|
105
|
+
});
|
|
106
|
+
await pendingPromise;
|
|
107
|
+
if (aborted()) break;
|
|
108
|
+
|
|
109
|
+
const events = await _readEvents(sessionId, { targetPath, tail: 0, since: lastTs });
|
|
110
|
+
for (const event of events) {
|
|
111
|
+
const candidate = event.ts || event.timestamp;
|
|
112
|
+
if (lastTs && candidate && candidate <= lastTs) continue;
|
|
113
|
+
if (candidate) lastTs = candidate;
|
|
114
|
+
queue.push({ source: "fs", event });
|
|
115
|
+
}
|
|
116
|
+
while (queue.length > 0) {
|
|
117
|
+
yield queue.shift();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
try {
|
|
122
|
+
watcher.close();
|
|
123
|
+
} catch {
|
|
124
|
+
/* swallow */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Subscribe to the API's SSE stream for a session. Emits each parsed
|
|
131
|
+
* data: line as `{ source: "sse", event }`. Auto-reconnects on
|
|
132
|
+
* connection drop with exponential backoff capped at 30s.
|
|
133
|
+
*
|
|
134
|
+
* `_sseFetch` defaults to `fetch` but tests can stub it.
|
|
135
|
+
*/
|
|
136
|
+
export async function* watchRemoteStream({
|
|
137
|
+
apiBaseUrl,
|
|
138
|
+
sessionId,
|
|
139
|
+
token,
|
|
140
|
+
signal,
|
|
141
|
+
_sseFetch = fetch,
|
|
142
|
+
reconnectBackoffMs = DEFAULT_RECONNECT_BACKOFF_MS,
|
|
143
|
+
} = {}) {
|
|
144
|
+
if (!apiBaseUrl || !sessionId || !token) return;
|
|
145
|
+
const endpoint = `${apiBaseUrl.replace(/\/+$/, "")}/api/v1/sessions/${encodeURIComponent(
|
|
146
|
+
sessionId,
|
|
147
|
+
)}/stream`;
|
|
148
|
+
let backoff = reconnectBackoffMs;
|
|
149
|
+
|
|
150
|
+
while (!signal?.aborted) {
|
|
151
|
+
let response;
|
|
152
|
+
try {
|
|
153
|
+
response = await _sseFetch(endpoint, {
|
|
154
|
+
method: "GET",
|
|
155
|
+
headers: {
|
|
156
|
+
Authorization: `Bearer ${token}`,
|
|
157
|
+
Accept: "text/event-stream",
|
|
158
|
+
},
|
|
159
|
+
signal,
|
|
160
|
+
});
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (signal?.aborted) return;
|
|
163
|
+
yield { source: "sse", error: String(err?.message || err) };
|
|
164
|
+
await sleep(backoff);
|
|
165
|
+
backoff = Math.min(backoff * 2, MAX_RECONNECT_BACKOFF_MS);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!response || !response.ok || !response.body) {
|
|
170
|
+
yield { source: "sse", error: `HTTP ${response?.status || "?"}` };
|
|
171
|
+
await sleep(backoff);
|
|
172
|
+
backoff = Math.min(backoff * 2, MAX_RECONNECT_BACKOFF_MS);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
backoff = reconnectBackoffMs;
|
|
177
|
+
const reader = response.body.getReader();
|
|
178
|
+
const decoder = new TextDecoder();
|
|
179
|
+
let buffer = "";
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
while (!signal?.aborted) {
|
|
183
|
+
const { value, done } = await reader.read();
|
|
184
|
+
if (done) break;
|
|
185
|
+
buffer += decoder.decode(value, { stream: true });
|
|
186
|
+
const frames = buffer.split(/\n\n/);
|
|
187
|
+
buffer = frames.pop() || "";
|
|
188
|
+
for (const frame of frames) {
|
|
189
|
+
for (const line of frame.split(/\r?\n/)) {
|
|
190
|
+
if (!line.startsWith("data:")) continue;
|
|
191
|
+
const payload = line.slice(5).trim();
|
|
192
|
+
if (!payload || payload === "[done]") continue;
|
|
193
|
+
try {
|
|
194
|
+
const event = JSON.parse(payload);
|
|
195
|
+
yield { source: "sse", event };
|
|
196
|
+
} catch {
|
|
197
|
+
yield { source: "sse", raw: payload };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} finally {
|
|
203
|
+
try {
|
|
204
|
+
await reader.cancel();
|
|
205
|
+
} catch {
|
|
206
|
+
/* swallow */
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (signal?.aborted) return;
|
|
211
|
+
await sleep(backoff);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Compose `fs.watch` and SSE into one event stream. Each emitted event
|
|
217
|
+
* carries its `source` so consumers can tell which lane saw it first;
|
|
218
|
+
* we dedup by event id so the same event arriving on both lanes only
|
|
219
|
+
* surfaces once.
|
|
220
|
+
*
|
|
221
|
+
* @param {object} params
|
|
222
|
+
* @param {string} params.sessionId
|
|
223
|
+
* @param {string} [params.targetPath]
|
|
224
|
+
* @param {string} [params.apiBaseUrl]
|
|
225
|
+
* @param {string} [params.token]
|
|
226
|
+
* @param {AbortSignal} [params.signal]
|
|
227
|
+
* @returns {AsyncIterable<{source: "fs"|"sse", event?: object, raw?: string, error?: string}>}
|
|
228
|
+
*/
|
|
229
|
+
export async function* mergeLiveSources({
|
|
230
|
+
sessionId,
|
|
231
|
+
targetPath,
|
|
232
|
+
apiBaseUrl,
|
|
233
|
+
token,
|
|
234
|
+
signal,
|
|
235
|
+
_localIterator,
|
|
236
|
+
_remoteIterator,
|
|
237
|
+
} = {}) {
|
|
238
|
+
if (!sessionId) return;
|
|
239
|
+
|
|
240
|
+
const localIterable = _localIterator
|
|
241
|
+
? _localIterator
|
|
242
|
+
: watchLocalStream({ sessionId, targetPath, signal });
|
|
243
|
+
const remoteIterable =
|
|
244
|
+
_remoteIterator || (apiBaseUrl && token)
|
|
245
|
+
? _remoteIterator
|
|
246
|
+
? _remoteIterator
|
|
247
|
+
: watchRemoteStream({ apiBaseUrl, sessionId, token, signal })
|
|
248
|
+
: null;
|
|
249
|
+
|
|
250
|
+
const seen = new Set();
|
|
251
|
+
const queue = [];
|
|
252
|
+
let pending = null;
|
|
253
|
+
|
|
254
|
+
const wakeUp = () => {
|
|
255
|
+
if (pending) {
|
|
256
|
+
const r = pending;
|
|
257
|
+
pending = null;
|
|
258
|
+
r();
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
async function pump(iterable) {
|
|
263
|
+
if (!iterable) return;
|
|
264
|
+
try {
|
|
265
|
+
for await (const item of iterable) {
|
|
266
|
+
queue.push(item);
|
|
267
|
+
wakeUp();
|
|
268
|
+
if (signal?.aborted) break;
|
|
269
|
+
}
|
|
270
|
+
} catch (err) {
|
|
271
|
+
queue.push({ source: "merge", error: String(err?.message || err) });
|
|
272
|
+
wakeUp();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
pump(localIterable);
|
|
277
|
+
if (remoteIterable) pump(remoteIterable);
|
|
278
|
+
|
|
279
|
+
// Make sure abort wakes the iterator promptly so the consumer doesn't
|
|
280
|
+
// hang waiting on a `pending` promise that nothing is going to
|
|
281
|
+
// resolve once the upstream sources finish.
|
|
282
|
+
if (signal) signal.addEventListener("abort", () => wakeUp(), { once: true });
|
|
283
|
+
|
|
284
|
+
while (!signal?.aborted) {
|
|
285
|
+
if (queue.length === 0) {
|
|
286
|
+
await new Promise((resolve) => {
|
|
287
|
+
pending = resolve;
|
|
288
|
+
});
|
|
289
|
+
if (signal?.aborted) break;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const item = queue.shift();
|
|
293
|
+
if (item.event) {
|
|
294
|
+
const key = eventKey(item.event);
|
|
295
|
+
if (key) {
|
|
296
|
+
if (seen.has(key)) continue;
|
|
297
|
+
seen.add(key);
|
|
298
|
+
if (seen.size > 5000) {
|
|
299
|
+
// bound memory — older keys roll out
|
|
300
|
+
const trimmed = Array.from(seen).slice(-2500);
|
|
301
|
+
seen.clear();
|
|
302
|
+
for (const k of trimmed) seen.add(k);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
yield item;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session message preview — used by `slc session history` to show
|
|
3
|
+
* the last meaningful line of each past conversation, ChatGPT-style.
|
|
4
|
+
*
|
|
5
|
+
* "Meaningful" filters out heartbeats / agent_join / file-lock churn
|
|
6
|
+
* so the preview doesn't get drowned in machine traffic.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readStream } from "./stream.js";
|
|
10
|
+
|
|
11
|
+
const PREVIEW_EVENTS = new Set([
|
|
12
|
+
"session_message",
|
|
13
|
+
"session_say",
|
|
14
|
+
"agent_response",
|
|
15
|
+
"human_relay",
|
|
16
|
+
"daemon_alert",
|
|
17
|
+
"session_admin_kill",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const HEAD_LIMIT = 40;
|
|
21
|
+
const PREVIEW_TAIL_SCAN = 50;
|
|
22
|
+
|
|
23
|
+
function trim(value, limit = HEAD_LIMIT) {
|
|
24
|
+
const text = String(value == null ? "" : value).trim();
|
|
25
|
+
if (!text) return "";
|
|
26
|
+
if (text.length <= limit) return text;
|
|
27
|
+
return `${text.slice(0, Math.max(0, limit - 1))}…`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Pick the most recent user-visible message from an event list. Returns
|
|
32
|
+
* the head of its body and the speaker so the caller can render
|
|
33
|
+
* `<agentId>: <message>`.
|
|
34
|
+
*
|
|
35
|
+
* @param {Array<object>} events
|
|
36
|
+
* @returns {{ts: string|null, agentId: string|null, kind: string|null, message: string|null}}
|
|
37
|
+
*/
|
|
38
|
+
export function pickLatestPreview(events = []) {
|
|
39
|
+
if (!Array.isArray(events) || events.length === 0) {
|
|
40
|
+
return { ts: null, agentId: null, kind: null, message: null };
|
|
41
|
+
}
|
|
42
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
43
|
+
const event = events[i] || {};
|
|
44
|
+
const kind = String(event.event || event.type || "").trim();
|
|
45
|
+
if (!kind || !PREVIEW_EVENTS.has(kind)) continue;
|
|
46
|
+
const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
|
|
47
|
+
const text =
|
|
48
|
+
payload.message ||
|
|
49
|
+
payload.response ||
|
|
50
|
+
payload.alert ||
|
|
51
|
+
payload.reason ||
|
|
52
|
+
payload.text;
|
|
53
|
+
if (!text) continue;
|
|
54
|
+
return {
|
|
55
|
+
ts: event.ts || event.timestamp || null,
|
|
56
|
+
agentId:
|
|
57
|
+
(event.agent && event.agent.id) ||
|
|
58
|
+
event.agentId ||
|
|
59
|
+
payload.agentId ||
|
|
60
|
+
null,
|
|
61
|
+
kind,
|
|
62
|
+
message: trim(text),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return { ts: null, agentId: null, kind: null, message: null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Lift a preview line for a single session by tailing the stream.
|
|
70
|
+
* Failures are non-fatal — missing stream / parse errors yield a null
|
|
71
|
+
* preview rather than throwing, so the history listing stays resilient
|
|
72
|
+
* across mixed-state caches.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} sessionId
|
|
75
|
+
* @param {{targetPath?: string, tail?: number}} [options]
|
|
76
|
+
* @returns {Promise<{ts: string|null, agentId: string|null, kind: string|null, message: string|null}>}
|
|
77
|
+
*/
|
|
78
|
+
export async function readSessionPreview(
|
|
79
|
+
sessionId,
|
|
80
|
+
{ targetPath = process.cwd(), tail = PREVIEW_TAIL_SCAN } = {},
|
|
81
|
+
) {
|
|
82
|
+
if (!sessionId) {
|
|
83
|
+
return { ts: null, agentId: null, kind: null, message: null };
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const events = await readStream(sessionId, { targetPath, tail });
|
|
87
|
+
return pickLatestPreview(events);
|
|
88
|
+
} catch {
|
|
89
|
+
return { ts: null, agentId: null, kind: null, message: null };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-shot remote hydrator for the local NDJSON stream.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `pollHumanMessages` (which speaks to the SentinelLayer API) with
|
|
5
|
+
* a persisted cursor + `appendToStream`, so a CLI invocation can pull
|
|
6
|
+
* web-posted messages into the local session log on demand. The
|
|
7
|
+
* background daemon already does this on a poll loop; this module is
|
|
8
|
+
* the synchronous counterpart that powers `slc session sync` and
|
|
9
|
+
* `slc session read --remote`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { pollHumanMessages } from "./sync.js";
|
|
13
|
+
import { appendToStream } from "./stream.js";
|
|
14
|
+
import { readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fetch new human messages for a session, append them to the local
|
|
18
|
+
* stream, and advance the persisted cursor. Returns a structured
|
|
19
|
+
* summary the CLI can render directly. Failures degrade — we never
|
|
20
|
+
* throw out of this helper for transient/auth issues so wrappers can
|
|
21
|
+
* still serve a local-only read.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} params
|
|
24
|
+
* @param {string} params.sessionId
|
|
25
|
+
* @param {string} [params.targetPath]
|
|
26
|
+
* @param {string|null} [params.since] - Override persisted cursor.
|
|
27
|
+
* @param {Function} [params._poll] - Test seam.
|
|
28
|
+
* @param {Function} [params._append] - Test seam.
|
|
29
|
+
* @returns {Promise<{ok: boolean, reason: string, relayed: number, dropped: number, cursor: string|null, persistedCursor: boolean}>}
|
|
30
|
+
*/
|
|
31
|
+
export async function hydrateSessionFromRemote({
|
|
32
|
+
sessionId,
|
|
33
|
+
targetPath = process.cwd(),
|
|
34
|
+
since = undefined,
|
|
35
|
+
_poll = pollHumanMessages,
|
|
36
|
+
_append = appendToStream,
|
|
37
|
+
} = {}) {
|
|
38
|
+
if (!sessionId || typeof sessionId !== "string") {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
reason: "invalid_session_id",
|
|
42
|
+
relayed: 0,
|
|
43
|
+
dropped: 0,
|
|
44
|
+
cursor: null,
|
|
45
|
+
persistedCursor: false,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const startCursor =
|
|
50
|
+
typeof since === "string" || since === null
|
|
51
|
+
? since
|
|
52
|
+
: await readSyncCursor(sessionId, { targetPath });
|
|
53
|
+
|
|
54
|
+
const polled = await _poll(sessionId, {
|
|
55
|
+
targetPath,
|
|
56
|
+
since: startCursor,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!polled || !polled.ok) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
reason: polled?.reason || "poll_failed",
|
|
63
|
+
relayed: 0,
|
|
64
|
+
dropped: Array.isArray(polled?.dropped) ? polled.dropped.length : 0,
|
|
65
|
+
cursor: typeof polled?.cursor === "string" ? polled.cursor : startCursor || null,
|
|
66
|
+
persistedCursor: false,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let relayed = 0;
|
|
71
|
+
for (const event of polled.events || []) {
|
|
72
|
+
try {
|
|
73
|
+
await _append(sessionId, event, { targetPath });
|
|
74
|
+
relayed += 1;
|
|
75
|
+
} catch {
|
|
76
|
+
// Append errors are observable via the stream but should not
|
|
77
|
+
// abort the rest of the batch — partial relay is still progress.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let persistedCursor = false;
|
|
82
|
+
if (typeof polled.cursor === "string" && polled.cursor.trim()) {
|
|
83
|
+
const result = await writeSyncCursor(sessionId, polled.cursor, { targetPath }).catch(() => null);
|
|
84
|
+
persistedCursor = Boolean(result && result.written);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
ok: true,
|
|
89
|
+
reason: "",
|
|
90
|
+
relayed,
|
|
91
|
+
dropped: Array.isArray(polled.dropped) ? polled.dropped.length : 0,
|
|
92
|
+
cursor: typeof polled.cursor === "string" ? polled.cursor : startCursor || null,
|
|
93
|
+
persistedCursor,
|
|
94
|
+
};
|
|
95
|
+
}
|
package/src/session/store.js
CHANGED
|
@@ -484,6 +484,52 @@ export async function listActiveSessions({ targetPath = process.cwd() } = {}) {
|
|
|
484
484
|
return sessions;
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
+
/**
|
|
488
|
+
* List every session known to the local cache. Unlike
|
|
489
|
+
* `listActiveSessions`, this includes archived AND expired sessions so
|
|
490
|
+
* the CLI can surface past conversations the way ChatGPT exposes its
|
|
491
|
+
* left-rail history.
|
|
492
|
+
*
|
|
493
|
+
* Each entry carries `archiveStatus` (`"active"` | `"archived"` |
|
|
494
|
+
* `"expired"`) so the consumer can group/filter without re-deriving
|
|
495
|
+
* lifecycle from the raw timestamps.
|
|
496
|
+
*
|
|
497
|
+
* @param {{targetPath?: string}} [options]
|
|
498
|
+
* @returns {Promise<Array<object>>}
|
|
499
|
+
*/
|
|
500
|
+
export async function listAllSessions({ targetPath = process.cwd() } = {}) {
|
|
501
|
+
const resolvedTargetPath = path.resolve(String(targetPath || "."));
|
|
502
|
+
const sessionsRoot = resolveSessionsRoot({ targetPath: resolvedTargetPath });
|
|
503
|
+
let entries = [];
|
|
504
|
+
try {
|
|
505
|
+
entries = await fsp.readdir(sessionsRoot, { withFileTypes: true });
|
|
506
|
+
} catch (error) {
|
|
507
|
+
if (error && typeof error === "object" && error.code === "ENOENT") {
|
|
508
|
+
return [];
|
|
509
|
+
}
|
|
510
|
+
throw error;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const sessions = [];
|
|
514
|
+
for (const entry of entries) {
|
|
515
|
+
if (!entry.isDirectory()) continue;
|
|
516
|
+
const loaded = await loadMetadata(entry.name, { targetPath: resolvedTargetPath });
|
|
517
|
+
if (!loaded) continue;
|
|
518
|
+
|
|
519
|
+
const payload = buildSessionPayload(loaded.metadata, loaded.paths);
|
|
520
|
+
let archiveStatus = "active";
|
|
521
|
+
if (loaded.metadata.status === SESSION_STATUS_ARCHIVED) {
|
|
522
|
+
archiveStatus = "archived";
|
|
523
|
+
} else if (isExpired(loaded.metadata)) {
|
|
524
|
+
archiveStatus = "expired";
|
|
525
|
+
}
|
|
526
|
+
sessions.push({ ...payload, archiveStatus });
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
sessions.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
|
530
|
+
return sessions;
|
|
531
|
+
}
|
|
532
|
+
|
|
487
533
|
export async function renewSession(sessionId, { targetPath = process.cwd() } = {}) {
|
|
488
534
|
const loaded = await loadMetadata(sessionId, { targetPath });
|
|
489
535
|
if (!loaded) {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session sync cursor persistence.
|
|
3
|
+
*
|
|
4
|
+
* The daemon keeps the human-message poll cursor in memory; one-shot CLI
|
|
5
|
+
* commands (`slc session sync`, `slc session read --remote`) need a
|
|
6
|
+
* cursor that survives across invocations so successive runs only fetch
|
|
7
|
+
* what is new. We persist the cursor next to the stream NDJSON in the
|
|
8
|
+
* session directory.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fsp from "node:fs/promises";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
import { resolveSessionDir } from "./paths.js";
|
|
15
|
+
|
|
16
|
+
function cursorPath(sessionId, { targetPath } = {}) {
|
|
17
|
+
return path.join(resolveSessionDir(sessionId, { targetPath }), "remote-sync-cursor.json");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read the persisted human-message cursor for a session. Returns `null`
|
|
22
|
+
* when no cursor has been recorded yet, or when the file is missing,
|
|
23
|
+
* empty, or malformed — callers should treat that as "first sync".
|
|
24
|
+
*
|
|
25
|
+
* @param {string} sessionId
|
|
26
|
+
* @param {{targetPath?: string}} [options]
|
|
27
|
+
* @returns {Promise<string|null>}
|
|
28
|
+
*/
|
|
29
|
+
export async function readSyncCursor(sessionId, { targetPath } = {}) {
|
|
30
|
+
if (!sessionId) return null;
|
|
31
|
+
const filePath = cursorPath(sessionId, { targetPath });
|
|
32
|
+
try {
|
|
33
|
+
const raw = await fsp.readFile(filePath, "utf-8");
|
|
34
|
+
const parsed = JSON.parse(raw);
|
|
35
|
+
const cursor = typeof parsed?.cursor === "string" ? parsed.cursor.trim() : "";
|
|
36
|
+
return cursor || null;
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (err && err.code === "ENOENT") return null;
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Persist the human-message cursor for a session. No-op when cursor is
|
|
45
|
+
* empty so we never overwrite a real value with an empty one.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} sessionId
|
|
48
|
+
* @param {string|null|undefined} cursor
|
|
49
|
+
* @param {{targetPath?: string}} [options]
|
|
50
|
+
* @returns {Promise<{written: boolean, path: string}>}
|
|
51
|
+
*/
|
|
52
|
+
export async function writeSyncCursor(sessionId, cursor, { targetPath } = {}) {
|
|
53
|
+
const filePath = cursorPath(sessionId, { targetPath });
|
|
54
|
+
const normalized = typeof cursor === "string" ? cursor.trim() : "";
|
|
55
|
+
if (!sessionId || !normalized) {
|
|
56
|
+
return { written: false, path: filePath };
|
|
57
|
+
}
|
|
58
|
+
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
|
59
|
+
const payload = { cursor: normalized, updatedAt: new Date().toISOString() };
|
|
60
|
+
await fsp.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
|
|
61
|
+
return { written: true, path: filePath };
|
|
62
|
+
}
|