@modelprofile.com/flexharness 3.6.0 → 3.7.0

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/ts/utils.json.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  import { FlexHarnessStoreFormatError, FlexHarnessValidationError } from './errors.js';
2
+ import {
3
+ FLEX_REVERSION_MAXIMUM_LIMITS,
4
+ FLEX_REVERSION_REFERENCE_MAX_BYTES,
5
+ } from './interfaces.js';
2
6
  import type {
3
7
  IFlexJsonLimits,
4
8
  IFlexPermissionSnapshot,
5
- IFlexProjectionSnapshot,
9
+ IFlexProjectionSnapshotV1,
10
+ IFlexProjectionSnapshotV2,
6
11
  IFlexScopeSnapshot,
7
12
  TFlexAgentModelMessage,
8
13
  TJsonValue,
@@ -390,8 +395,8 @@ function requireString(value: unknown, path: string): string {
390
395
  return value;
391
396
  }
392
397
 
393
- function requireOptionalString(value: unknown, path: string): void {
394
- if (value !== undefined) requireString(value, path);
398
+ function requireOptionalString(value: unknown, path: string): string | undefined {
399
+ return value === undefined ? undefined : requireString(value, path);
395
400
  }
396
401
 
397
402
  function requireNonNegativeNumber(value: unknown, path: string): void {
@@ -873,15 +878,12 @@ export function assertFlexScopeSnapshot(value: unknown): asserts value is IFlexS
873
878
  }
874
879
  }
875
880
 
876
- export function assertFlexProjectionSnapshot(
877
- value: unknown,
878
- ): asserts value is IFlexProjectionSnapshot {
879
- const snapshot = validateSnapshotHeader(value, [
880
- 'schemaVersion',
881
- 'revision',
882
- 'messages',
883
- 'stagedTerminals',
884
- ]);
881
+ interface IValidatedProjectionBody {
882
+ messages: IValidatedMessageIdentity[];
883
+ stagedUsers: IValidatedMessageIdentity[];
884
+ }
885
+
886
+ function validateProjectionBody(snapshot: Record<string, unknown>): IValidatedProjectionBody {
885
887
  const visibleMessages = validateMessages(snapshot.messages, '$snapshot.messages');
886
888
  let projectionSessionId = visibleMessages[0]?.sessionId;
887
889
  if (visibleMessages.some((message) => message.sessionId !== projectionSessionId)) {
@@ -892,6 +894,7 @@ export function assertFlexProjectionSnapshot(
892
894
  }
893
895
  const runIds = new Set<string>();
894
896
  const stagedMessageIds = new Set<string>();
897
+ const stagedUsers: IValidatedMessageIdentity[] = [];
895
898
  for (let index = 0; index < snapshot.stagedTerminals.length; index++) {
896
899
  const path = `$snapshot.stagedTerminals[${index}]`;
897
900
  const terminal = requireRecord(snapshot.stagedTerminals[index], path);
@@ -928,6 +931,10 @@ export function assertFlexProjectionSnapshot(
928
931
  if (assistant.status !== terminal.status) {
929
932
  throw new FlexHarnessStoreFormatError(`${path}.status does not match its assistant message.`);
930
933
  }
934
+ if (user.status !== terminal.status) {
935
+ throw new FlexHarnessStoreFormatError(`${path}.status does not match its user message.`);
936
+ }
937
+ stagedUsers.push(user);
931
938
  projectionSessionId ??= user.sessionId;
932
939
  if (user.sessionId !== projectionSessionId) {
933
940
  throw new FlexHarnessStoreFormatError(`${path} belongs to another session.`);
@@ -945,6 +952,271 @@ export function assertFlexProjectionSnapshot(
945
952
  requireOptionalString(terminal.finishReason, `${path}.finishReason`);
946
953
  if (terminal.steps !== undefined) requireNonNegativeInteger(terminal.steps, `${path}.steps`);
947
954
  }
955
+ return { messages: visibleMessages, stagedUsers };
956
+ }
957
+
958
+ export function assertFlexProjectionSnapshotV1(
959
+ value: unknown,
960
+ ): asserts value is IFlexProjectionSnapshotV1 {
961
+ const snapshot = validateSnapshotHeader(value, [
962
+ 'schemaVersion',
963
+ 'revision',
964
+ 'messages',
965
+ 'stagedTerminals',
966
+ ]);
967
+ validateProjectionBody(snapshot);
968
+ }
969
+
970
+ export function assertFlexProjectionSnapshotV2(
971
+ value: unknown,
972
+ ): asserts value is IFlexProjectionSnapshotV2 {
973
+ assertJsonSerializable(value, '$snapshot');
974
+ const snapshot = requireRecord(value, '$snapshot');
975
+ requireOnlyKeys(snapshot, [
976
+ 'schemaVersion',
977
+ 'revision',
978
+ 'messages',
979
+ 'stagedTerminals',
980
+ 'reversionSegments',
981
+ 'revertCursor',
982
+ 'excludedRunIds',
983
+ 'pendingReversion',
984
+ 'pendingReversionReleases',
985
+ ], '$snapshot');
986
+ if (snapshot.schemaVersion !== 2) {
987
+ throw new FlexHarnessStoreFormatError('Projection snapshot schemaVersion must be 2.');
988
+ }
989
+ requireNonNegativeInteger(snapshot.revision, 'Snapshot revision');
990
+ const projectionBody = validateProjectionBody(snapshot);
991
+ const segments = Array.isArray(snapshot.reversionSegments)
992
+ ? snapshot.reversionSegments
993
+ : (() => { throw new FlexHarnessStoreFormatError('Snapshot reversionSegments must be an array.'); })();
994
+ if (segments.length > FLEX_REVERSION_MAXIMUM_LIMITS.maxSegments) {
995
+ throw new FlexHarnessStoreFormatError('Snapshot reversionSegments exceeds its hard limit.');
996
+ }
997
+ const segmentRunIds = new Set<string>();
998
+ const segmentCaptureIds = new Set<string>();
999
+ const segmentEventIds = new Set<string>();
1000
+ const capturingWorkspaceSegments = new Map<string, string>();
1001
+ for (let index = 0; index < segments.length; index++) {
1002
+ const path = `$snapshot.reversionSegments[${index}]`;
1003
+ const segment = requireRecord(segments[index], path);
1004
+ requireOnlyKeys(segment, [
1005
+ 'runId',
1006
+ 'userMessageId',
1007
+ 'status',
1008
+ 'contextAvailable',
1009
+ 'eventIds',
1010
+ 'workspaceCaptured',
1011
+ 'captureId',
1012
+ 'workspaceReference',
1013
+ ], path);
1014
+ const runId = requireString(segment.runId, `${path}.runId`);
1015
+ const userMessageId = requireString(segment.userMessageId, `${path}.userMessageId`);
1016
+ if (segmentRunIds.has(runId)) {
1017
+ throw new FlexHarnessStoreFormatError(`Snapshot contains duplicate reversion run "${runId}".`);
1018
+ }
1019
+ segmentRunIds.add(runId);
1020
+ if (!['capturing', 'completed', 'failed', 'cancelled'].includes(String(segment.status))) {
1021
+ throw new FlexHarnessStoreFormatError(`${path}.status is invalid.`);
1022
+ }
1023
+ if (typeof segment.contextAvailable !== 'boolean') {
1024
+ throw new FlexHarnessStoreFormatError(`${path}.contextAvailable must be a boolean.`);
1025
+ }
1026
+ const eventIds = requireStringArray(segment.eventIds, `${path}.eventIds`);
1027
+ if (eventIds.some((id) => !id) || new Set(eventIds).size !== eventIds.length) {
1028
+ throw new FlexHarnessStoreFormatError(`${path}.eventIds is invalid.`);
1029
+ }
1030
+ for (const eventId of eventIds) {
1031
+ if (segmentEventIds.has(eventId)) {
1032
+ throw new FlexHarnessStoreFormatError(`Snapshot reuses reversion event "${eventId}".`);
1033
+ }
1034
+ segmentEventIds.add(eventId);
1035
+ }
1036
+ if (typeof segment.workspaceCaptured !== 'boolean') {
1037
+ throw new FlexHarnessStoreFormatError(`${path}.workspaceCaptured must be a boolean.`);
1038
+ }
1039
+ const captureId = requireOptionalString(segment.captureId, `${path}.captureId`);
1040
+ if (segment.workspaceCaptured && captureId === undefined) {
1041
+ throw new FlexHarnessStoreFormatError(`${path} is capture-backed without a capture ID.`);
1042
+ }
1043
+ if (captureId !== undefined) {
1044
+ if (segmentCaptureIds.has(captureId)) {
1045
+ throw new FlexHarnessStoreFormatError(`Snapshot contains duplicate capture "${captureId}".`);
1046
+ }
1047
+ segmentCaptureIds.add(captureId);
1048
+ if (segment.status === 'capturing') capturingWorkspaceSegments.set(runId, captureId);
1049
+ }
1050
+ if (!segment.workspaceCaptured && (captureId !== undefined || segment.workspaceReference !== undefined)) {
1051
+ throw new FlexHarnessStoreFormatError(`${path} is transcript-only but contains workspace capture data.`);
1052
+ }
1053
+ if (
1054
+ segment.workspaceCaptured
1055
+ && segment.status !== 'capturing'
1056
+ && segment.workspaceReference === undefined
1057
+ ) {
1058
+ throw new FlexHarnessStoreFormatError(`${path} is terminal but has no workspace reference.`);
1059
+ }
1060
+ if (segment.status === 'capturing' && segment.workspaceReference !== undefined) {
1061
+ throw new FlexHarnessStoreFormatError(`${path} is capturing but already has a workspace reference.`);
1062
+ }
1063
+ if (segment.workspaceReference !== undefined) {
1064
+ if (captureId === undefined) {
1065
+ throw new FlexHarnessStoreFormatError(`${path} has a workspace reference without a capture ID.`);
1066
+ }
1067
+ if (Buffer.byteLength(JSON.stringify(segment.workspaceReference)) > FLEX_REVERSION_REFERENCE_MAX_BYTES) {
1068
+ throw new FlexHarnessStoreFormatError(`${path}.workspaceReference exceeds its byte limit.`);
1069
+ }
1070
+ }
1071
+ const correlatedUsers = new Set([...projectionBody.messages, ...projectionBody.stagedUsers]
1072
+ .filter((message) =>
1073
+ message.role === 'user' && message.runId === runId && message.messageId === userMessageId)
1074
+ .map((message) => `${message.runId}:${message.messageId}`));
1075
+ if (correlatedUsers.size !== 1) {
1076
+ throw new FlexHarnessStoreFormatError(`${path} does not match exactly one projected user message.`);
1077
+ }
1078
+ }
1079
+ requireNonNegativeInteger(snapshot.revertCursor, '$snapshot.revertCursor');
1080
+ const candidateCount = segments.filter((segment) => {
1081
+ const record = segment as Record<string, unknown>;
1082
+ return record.status === 'completed';
1083
+ }).length;
1084
+ if (candidateCount > FLEX_REVERSION_MAXIMUM_LIMITS.maxCompletedTurns) {
1085
+ throw new FlexHarnessStoreFormatError('Snapshot completed reversion turns exceed their hard limit.');
1086
+ }
1087
+ if (Number(snapshot.revertCursor) > candidateCount) {
1088
+ throw new FlexHarnessStoreFormatError('Snapshot revertCursor exceeds its candidate count.');
1089
+ }
1090
+ const excludedRunIds = requireStringArray(snapshot.excludedRunIds, '$snapshot.excludedRunIds');
1091
+ if (
1092
+ excludedRunIds.some((id) => !id)
1093
+ || new Set(excludedRunIds).size !== excludedRunIds.length
1094
+ ) throw new FlexHarnessStoreFormatError('Snapshot excludedRunIds is invalid.');
1095
+ if (excludedRunIds.length > FLEX_REVERSION_MAXIMUM_LIMITS.maxExcludedRunIds) {
1096
+ throw new FlexHarnessStoreFormatError('Snapshot excludedRunIds exceeds its hard limit.');
1097
+ }
1098
+ if (snapshot.pendingReversion !== undefined) {
1099
+ const pending = requireRecord(snapshot.pendingReversion, '$snapshot.pendingReversion');
1100
+ if (pending.kind === 'capture') {
1101
+ requireOnlyKeys(pending, ['kind', 'runId', 'captureId', 'state'], '$snapshot.pendingReversion');
1102
+ const pendingRunId = requireString(pending.runId, '$snapshot.pendingReversion.runId');
1103
+ const pendingCaptureId = requireString(pending.captureId, '$snapshot.pendingReversion.captureId');
1104
+ if (!['preparing', 'prepared', 'finalizing'].includes(String(pending.state))) {
1105
+ throw new FlexHarnessStoreFormatError('Snapshot pending capture state is invalid.');
1106
+ }
1107
+ const matches = segments.filter((segment) => {
1108
+ const record = segment as Record<string, unknown>;
1109
+ return record.runId === pendingRunId
1110
+ && record.captureId === pendingCaptureId
1111
+ && record.workspaceCaptured === true
1112
+ && record.status === 'capturing';
1113
+ });
1114
+ if (matches.length !== 1) {
1115
+ throw new FlexHarnessStoreFormatError('Snapshot pending capture does not match exactly one segment.');
1116
+ }
1117
+ capturingWorkspaceSegments.delete(pendingRunId);
1118
+ } else if (pending.kind === 'apply') {
1119
+ requireOnlyKeys(pending, [
1120
+ 'kind',
1121
+ 'operationId',
1122
+ 'direction',
1123
+ 'fromCursor',
1124
+ 'toCursor',
1125
+ 'segmentRunIds',
1126
+ 'appliedRunIds',
1127
+ ], '$snapshot.pendingReversion');
1128
+ requireString(pending.operationId, '$snapshot.pendingReversion.operationId');
1129
+ if (!['undo', 'redo'].includes(String(pending.direction))) {
1130
+ throw new FlexHarnessStoreFormatError('Snapshot pending apply direction is invalid.');
1131
+ }
1132
+ requireNonNegativeInteger(pending.fromCursor, '$snapshot.pendingReversion.fromCursor');
1133
+ requireNonNegativeInteger(pending.toCursor, '$snapshot.pendingReversion.toCursor');
1134
+ const pendingRuns = requireStringArray(
1135
+ pending.segmentRunIds,
1136
+ '$snapshot.pendingReversion.segmentRunIds',
1137
+ );
1138
+ const appliedRuns = requireStringArray(
1139
+ pending.appliedRunIds,
1140
+ '$snapshot.pendingReversion.appliedRunIds',
1141
+ );
1142
+ if (new Set(pendingRuns).size !== pendingRuns.length || new Set(appliedRuns).size !== appliedRuns.length) {
1143
+ throw new FlexHarnessStoreFormatError('Snapshot pending apply run IDs are invalid.');
1144
+ }
1145
+ if (appliedRuns.some((runId) => !pendingRuns.includes(runId))) {
1146
+ throw new FlexHarnessStoreFormatError('Snapshot pending apply progress is invalid.');
1147
+ }
1148
+ const fromCursor = Number(pending.fromCursor);
1149
+ const toCursor = Number(pending.toCursor);
1150
+ const direction = pending.direction as 'undo' | 'redo';
1151
+ if (fromCursor !== Number(snapshot.revertCursor) || toCursor !== fromCursor + (direction === 'undo' ? -1 : 1)) {
1152
+ throw new FlexHarnessStoreFormatError('Snapshot pending apply cursor movement is invalid.');
1153
+ }
1154
+ const completed = segments.filter((segment) =>
1155
+ (segment as Record<string, unknown>).status === 'completed');
1156
+ const target = direction === 'undo' ? completed[fromCursor - 1] : completed[fromCursor];
1157
+ if (!target) throw new FlexHarnessStoreFormatError('Snapshot pending apply has no target candidate.');
1158
+ const targetRunId = (target as Record<string, unknown>).runId;
1159
+ const targetIndex = segments.findIndex((segment) =>
1160
+ (segment as Record<string, unknown>).runId === targetRunId);
1161
+ const start = target === completed[0] ? 0 : targetIndex;
1162
+ const next = completed[direction === 'undo' ? fromCursor : fromCursor + 1];
1163
+ const end = next
1164
+ ? segments.findIndex((segment) =>
1165
+ (segment as Record<string, unknown>).runId === (next as Record<string, unknown>).runId)
1166
+ : segments.length;
1167
+ const exactRuns = segments.slice(start, end).map((segment) =>
1168
+ String((segment as Record<string, unknown>).runId));
1169
+ if (JSON.stringify(pendingRuns) !== JSON.stringify(exactRuns)) {
1170
+ throw new FlexHarnessStoreFormatError('Snapshot pending apply segment unit is invalid.');
1171
+ }
1172
+ const applyOrder = direction === 'undo' ? [...exactRuns].reverse() : exactRuns;
1173
+ if (JSON.stringify(appliedRuns) !== JSON.stringify(applyOrder.slice(0, appliedRuns.length))) {
1174
+ throw new FlexHarnessStoreFormatError('Snapshot pending apply progress is out of order.');
1175
+ }
1176
+ } else {
1177
+ throw new FlexHarnessStoreFormatError('Snapshot pendingReversion kind is invalid.');
1178
+ }
1179
+ }
1180
+ if (capturingWorkspaceSegments.size > 0) {
1181
+ throw new FlexHarnessStoreFormatError(
1182
+ 'Snapshot contains a capture-backed capturing segment without exact pending ownership.',
1183
+ );
1184
+ }
1185
+ if (!Array.isArray(snapshot.pendingReversionReleases)) {
1186
+ throw new FlexHarnessStoreFormatError('Snapshot pendingReversionReleases must be an array.');
1187
+ }
1188
+ if (
1189
+ snapshot.pendingReversionReleases.length
1190
+ > FLEX_REVERSION_MAXIMUM_LIMITS.maxPendingReversionReleases
1191
+ ) {
1192
+ throw new FlexHarnessStoreFormatError('Snapshot pending reversion releases exceed their hard limit.');
1193
+ }
1194
+ const releaseCaptureIds = new Set<string>();
1195
+ for (let index = 0; index < snapshot.pendingReversionReleases.length; index++) {
1196
+ const path = `$snapshot.pendingReversionReleases[${index}]`;
1197
+ const release = requireRecord(snapshot.pendingReversionReleases[index], path);
1198
+ requireOnlyKeys(release, ['runId', 'captureId', 'reference'], path);
1199
+ requireString(release.runId, `${path}.runId`);
1200
+ const captureId = requireString(release.captureId, `${path}.captureId`);
1201
+ if (releaseCaptureIds.has(captureId)) {
1202
+ throw new FlexHarnessStoreFormatError(`Snapshot contains duplicate release capture "${captureId}".`);
1203
+ }
1204
+ releaseCaptureIds.add(captureId);
1205
+ if (!Object.prototype.hasOwnProperty.call(release, 'reference')) {
1206
+ throw new FlexHarnessStoreFormatError(`${path}.reference is required.`);
1207
+ }
1208
+ if (Buffer.byteLength(JSON.stringify(release.reference)) > FLEX_REVERSION_REFERENCE_MAX_BYTES) {
1209
+ throw new FlexHarnessStoreFormatError(`${path}.reference exceeds its byte limit.`);
1210
+ }
1211
+ }
1212
+ }
1213
+
1214
+ export function assertFlexProjectionSnapshot(
1215
+ value: unknown,
1216
+ ): asserts value is IFlexProjectionSnapshotV1 | IFlexProjectionSnapshotV2 {
1217
+ const record = requireRecord(value, '$snapshot');
1218
+ if (record.schemaVersion === 1) assertFlexProjectionSnapshotV1(value);
1219
+ else assertFlexProjectionSnapshotV2(value);
948
1220
  }
949
1221
 
950
1222
  export function assertFlexPermissionSnapshot(
@@ -0,0 +1,178 @@
1
+ import { FlexHarnessValidationError } from './errors.js';
2
+ import type {
3
+ IFlexParsedSlashCommand,
4
+ TFlexSlashCommandParseResult,
5
+ } from './interfaces.js';
6
+
7
+ export const FLEX_SLASH_COMMAND_MAX_INPUT_BYTES = 768 * 1024;
8
+
9
+ const slashCommandNamePattern = /^[a-z][a-z0-9-]{0,63}$/u;
10
+ const slashCommandPattern = /^\/([a-z][a-z0-9-]{0,63})(?:$|(\s+)([\s\S]*))$/u;
11
+ const slashCommandArgumentsPattern = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi;
12
+ const slashCommandQuoteTrimPattern = /^["']|["']$/g;
13
+ const slashCommandPlaceholderPattern = /\$(\d+)/g;
14
+ const slashCommandAllPlaceholderPattern = /\$ARGUMENTS|\$(\d+)/g;
15
+
16
+ export const FLEX_SLASH_COMMAND_INITIALIZE_TEMPLATE = `Create or update \`AGENTS.md\` for this repository.
17
+
18
+ The goal is a compact instruction file that helps future OpenCode sessions avoid mistakes and ramp up quickly. Every line should answer: "Would an agent likely miss this without help?" If not, leave it out.
19
+
20
+ User-provided focus or constraints (honor these):
21
+ $ARGUMENTS
22
+
23
+ ## How to investigate
24
+
25
+ Read the highest-value sources first:
26
+ - \`README*\`, root manifests, workspace config, lockfiles
27
+ - build, test, lint, formatter, typecheck, and codegen config
28
+ - CI workflows and pre-commit / task runner config
29
+ - existing instruction files (\`AGENTS.md\`, \`CLAUDE.md\`, \`.cursor/rules/\`, \`.cursorrules\`, \`.github/copilot-instructions.md\`)
30
+ - repo-local OpenCode config such as \`opencode.json\`
31
+
32
+ If architecture is still unclear after reading config and docs, inspect a small number of representative code files to find the real entrypoints, package boundaries, and execution flow. Prefer reading the files that explain how the system is wired together over random leaf files.
33
+
34
+ Prefer executable sources of truth over prose. If docs conflict with config or scripts, trust the executable source and only keep what you can verify.
35
+
36
+ ## What to extract
37
+
38
+ Look for the highest-signal facts for an agent working in this repo:
39
+ - exact developer commands, especially non-obvious ones
40
+ - how to run a single test, a single package, or a focused verification step
41
+ - required command order when it matters, such as \`lint -> typecheck -> test\`
42
+ - monorepo or multi-package boundaries, ownership of major directories, and the real app/library entrypoints
43
+ - framework or toolchain quirks: generated code, migrations, codegen, build artifacts, special env loading, dev servers, infra deploy flow
44
+ - repo-specific style or workflow conventions that differ from defaults
45
+ - testing quirks: fixtures, integration test prerequisites, snapshot workflows, required services, flaky or expensive suites
46
+ - important constraints from existing instruction files worth preserving
47
+
48
+ Good \`AGENTS.md\` content is usually hard-earned context that took reading multiple files to infer.
49
+
50
+ ## Questions
51
+
52
+ Only ask the user questions if the repo cannot answer something important. Use the \`question\` tool for one short batch at most.
53
+
54
+ Good questions:
55
+ - undocumented team conventions
56
+ - branch / PR / release expectations
57
+ - missing setup or test prerequisites that are known but not written down
58
+
59
+ Do not ask about anything the repo already makes clear.
60
+
61
+ ## Writing rules
62
+
63
+ Include only high-signal, repo-specific guidance such as:
64
+ - exact commands and shortcuts the agent would otherwise guess wrong
65
+ - architecture notes that are not obvious from filenames
66
+ - conventions that differ from language or framework defaults
67
+ - setup requirements, environment quirks, and operational gotchas
68
+ - references to existing instruction sources that matter
69
+
70
+ Exclude:
71
+ - generic software advice
72
+ - long tutorials or exhaustive file trees
73
+ - obvious language conventions
74
+ - speculative claims or anything you could not verify
75
+ - content better stored in another file referenced via \`opencode.json\` \`instructions\`
76
+
77
+ When in doubt, omit.
78
+
79
+ Prefer short sections and bullets. If the repo is simple, keep the file simple. If the repo is large, summarize the few structural facts that actually change how an agent should work.
80
+
81
+ If \`AGENTS.md\` already exists in the active workspace, improve it in place rather than rewriting blindly. Preserve verified useful guidance, delete fluff or stale claims, and reconcile it with the current codebase.
82
+ `;
83
+
84
+ export function isValidSlashCommandName(name: string): boolean {
85
+ return slashCommandNamePattern.test(name);
86
+ }
87
+
88
+ function tokenizeSlashCommandArguments(rawArguments: string): string[] {
89
+ const tokens = rawArguments.match(slashCommandArgumentsPattern) ?? [];
90
+ return tokens.map((token) => token.replace(slashCommandQuoteTrimPattern, ''));
91
+ }
92
+
93
+ export function parseSlashCommand(input: string): TFlexSlashCommandParseResult {
94
+ if (typeof input !== 'string') {
95
+ throw new FlexHarnessValidationError('Slash command input must be a string.');
96
+ }
97
+ if (!input.startsWith('/')) return Object.freeze({ type: 'not-command' });
98
+ if (Buffer.byteLength(input, 'utf8') > FLEX_SLASH_COMMAND_MAX_INPUT_BYTES) {
99
+ return Object.freeze({
100
+ type: 'malformed',
101
+ reason: `Slash command input exceeds ${FLEX_SLASH_COMMAND_MAX_INPUT_BYTES} UTF-8 bytes.`,
102
+ });
103
+ }
104
+ const match = slashCommandPattern.exec(input);
105
+ if (!match) {
106
+ return Object.freeze({
107
+ type: 'malformed',
108
+ reason: 'Slash command syntax is invalid.',
109
+ });
110
+ }
111
+ const arguments_ = tokenizeSlashCommandArguments(match[3] ?? '');
112
+ Object.freeze(arguments_);
113
+ return Object.freeze({
114
+ type: 'parsed',
115
+ input,
116
+ name: match[1],
117
+ rawArguments: match[3] ?? '',
118
+ arguments: arguments_,
119
+ } satisfies IFlexParsedSlashCommand);
120
+ }
121
+
122
+ export function slashCommandTemplateHints(template: string): string[] {
123
+ const hints = [...new Set(template.match(/\$\d+/g) ?? [])].sort();
124
+ if (template.includes('$ARGUMENTS')) hints.push('$ARGUMENTS');
125
+ return hints;
126
+ }
127
+
128
+ export function expandSlashCommandTemplate(
129
+ template: string,
130
+ rawArguments: string,
131
+ arguments_: readonly string[],
132
+ ): string {
133
+ const placeholders = template.match(slashCommandPlaceholderPattern) ?? [];
134
+ let highestPosition = 0;
135
+ for (const placeholder of placeholders) {
136
+ highestPosition = Math.max(highestPosition, Number(placeholder.slice(1)));
137
+ }
138
+ const segments: string[] = [];
139
+ let lastIndex = 0;
140
+ let expandedBytes = 0;
141
+ for (const match of template.matchAll(slashCommandAllPlaceholderPattern)) {
142
+ const literal = template.slice(lastIndex, match.index);
143
+ let replacement: string;
144
+ if (match[0] === '$ARGUMENTS') {
145
+ replacement = rawArguments;
146
+ } else {
147
+ const position = Number(match[1]);
148
+ const argumentIndex = position - 1;
149
+ replacement = argumentIndex < 0 || argumentIndex >= arguments_.length
150
+ ? ''
151
+ : position === highestPosition
152
+ ? arguments_.slice(argumentIndex).join(' ')
153
+ : arguments_[argumentIndex];
154
+ }
155
+ expandedBytes += Buffer.byteLength(literal, 'utf8') + Buffer.byteLength(replacement, 'utf8');
156
+ if (expandedBytes > FLEX_SLASH_COMMAND_MAX_INPUT_BYTES) {
157
+ throw new FlexHarnessValidationError('Expanded slash command prompt exceeds the input limit.');
158
+ }
159
+ segments.push(literal, replacement);
160
+ lastIndex = match.index + match[0].length;
161
+ }
162
+ segments.push(template.slice(lastIndex));
163
+ expandedBytes += Buffer.byteLength(segments.at(-1)!, 'utf8');
164
+ if (expandedBytes > FLEX_SLASH_COMMAND_MAX_INPUT_BYTES) {
165
+ throw new FlexHarnessValidationError('Expanded slash command prompt exceeds the input limit.');
166
+ }
167
+ let expanded = segments.join('');
168
+ if (placeholders.length === 0 && !template.includes('$ARGUMENTS') && rawArguments.trim()) {
169
+ if (
170
+ expandedBytes + Buffer.byteLength(rawArguments, 'utf8') + 2
171
+ > FLEX_SLASH_COMMAND_MAX_INPUT_BYTES
172
+ ) {
173
+ throw new FlexHarnessValidationError('Expanded slash command prompt exceeds the input limit.');
174
+ }
175
+ expanded += `\n\n${rawArguments}`;
176
+ }
177
+ return expanded;
178
+ }
@@ -4,10 +4,11 @@ import type {
4
4
  IFlexHarnessStores,
5
5
  IFlexMessage,
6
6
  IFlexPermissionSnapshot,
7
- IFlexProjectionSnapshot,
7
+ IFlexProjectionSnapshotCurrent,
8
8
  IFlexScopeSnapshot,
9
9
  IFlexSession,
10
10
  TFlexAgentModelMessage,
11
+ TFlexProjectionSnapshot,
11
12
  TJsonValue,
12
13
  } from '../ts/interfaces.js';
13
14
  import {
@@ -44,7 +45,7 @@ interface ILegacyRun {
44
45
 
45
46
  interface ILegacySessionMigrationPlan {
46
47
  sessionId: string;
47
- projectionSnapshot: IFlexProjectionSnapshot;
48
+ projectionSnapshot: IFlexProjectionSnapshotCurrent;
48
49
  permissionSnapshot: IFlexPermissionSnapshot;
49
50
  agentEvents: plugins.TAgentEvent[];
50
51
  }
@@ -56,7 +57,7 @@ interface ILegacyMigrationPlan {
56
57
 
57
58
  interface IInspectedSessionDestination {
58
59
  plan: ILegacySessionMigrationPlan;
59
- projection: IFlexProjectionSnapshot | undefined;
60
+ projection: TFlexProjectionSnapshot | undefined;
60
61
  permission: IFlexPermissionSnapshot | undefined;
61
62
  eventStore: plugins.IAgentEventStoreV2;
62
63
  eventSnapshot: plugins.IAgentEventSnapshotV2 | undefined;
@@ -403,11 +404,15 @@ function createMigrationPlan(
403
404
  sessions: [stored.session],
404
405
  tombstones: [],
405
406
  };
406
- const projectionSnapshot: IFlexProjectionSnapshot = {
407
- schemaVersion: 1,
407
+ const projectionSnapshot: IFlexProjectionSnapshotCurrent = {
408
+ schemaVersion: 2,
408
409
  revision: 1,
409
410
  messages: stored.messages,
410
411
  stagedTerminals: [],
412
+ reversionSegments: [],
413
+ revertCursor: 0,
414
+ excludedRunIds: [],
415
+ pendingReversionReleases: [],
411
416
  };
412
417
  const permissionSnapshot: IFlexPermissionSnapshot = {
413
418
  schemaVersion: 1,