Zion Boggan
repos/TreeTrace/src/extract.js
zionboggan.com ↗
206 lines · javascript
History for this file →
1
import { truncate } from './util.js';
2
 
3
const KIND = {
4
  ROOT: 'root',
5
  DIRECTION: 'direction',
6
  CORRECTION: 'correction',
7
  SCOPE: 'scope-change',
8
  CHECKPOINT: 'checkpoint',
9
  QUESTION: 'question',
10
  REJECTION: 'rejection',
11
};
12
 
13
const CORRECTION_STRONG_OPENERS =
14
  /^(no[,.\s]|no$|not |don'?t |stop\b|wrong\b|undo\b|revert\b|nope\b|that'?s (not|wrong)|why did you)/i;
15
const CORRECTION_ANYWHERE =
16
  /(didn'?t work|doesn'?t work|not working|still (failing|broken|wrong|not)|that broke|you (missed|forgot|skipped|ignored)|redo (this|that|it)|go back|that'?s incorrect|not what i (asked|meant|wanted)|undo (this|that)|roll(?: |-)?back)/i;
17
 
18
const CORRECTION_SOFT_OPENERS = /^(wait\b|actually[,\s]|hold on\b|hmm[,\s]|instead[,\s])/i;
19
 
20
const SCOPE_ANYWHERE =
21
  /(also (add|build|make|create|include)|now (add|build|make|let'?s)|new (feature|requirement|idea)|let'?s also|switch to|pivot|change of plans|from now on|going forward|next phase|instead of .{3,40}(do|use|build|make)|scrap (that|this)|forget (that|this)|rather than)/i;
22
 
23
const CHECKPOINT_ANYWHERE =
24
  /^(commit|push|publish|ship|deploy|release)\b|(write (up|a) (summary|report|readme)|summari[sz]e (what|the|this)|status update|where are we|what'?s (left|remaining|the status)|wrap (this |it )?up|document (what|this|the)|hand ?off|save (your |our )?progress)/i;
25
 
26
const QUESTION_ONLY =
27
  /^(what|how|why|where|when|which|who|is|are|can|could|should|would|will|do|does|did)\b[^]*\?\s*$/i;
28
 
29
const CONTINUATION_RE =
30
  /^(y|yes|yep|yeah|ok|okay|k|sure|continue|cont|go|go ahead|do it|proceed|next|sounds good|looks good|lgtm|perfect|nice|good|great|approved?|yes please|please do|carry on|keep going|resume|finish|all good|that works|works|๐Ÿ‘|do that)[.! ]*$/i;
31
 
32
const SELECTION_RE = /^(?:option\s+)?([0-9]{1,2}|[a-d])[.)! ]*$/i;
33
 
34
const IGNORE_RE = /\bignore this\b/i;
35
 
36
const MAX_NUDGE_WORDS = 4;
37
 
38
export function classifyPrompts(sessions) {
39
  const nodes = [];
40
  let rootAssigned = false;
41
 
42
  for (const session of sessions) {
43
    let prevNode = null;
44
    for (const prompt of session.prompts) {
45
      const text = prompt.text;
46
      const words = text.split(/\s+/).filter(Boolean);
47
 
48
      if (prompt.isRejectionOnly) {
49
        const node = {
50
          id: null,
51
          uuid: prompt.uuid,
52
          parentUuid: prompt.parentUuid,
53
          sessionId: session.sessionId,
54
          ts: prompt.ts,
55
          text: '',
56
          title: makeRejectionTitle(prompt.rejections),
57
          kind: KIND.REJECTION,
58
          status: 'accepted',
59
          nudges: 0,
60
          afterInterruption: prompt.afterInterruption,
61
          actions: prompt.actions || [],
62
          thinking: prompt.thinking || 0,
63
          rejections: prompt.rejections || [],
64
          chars: 0,
65
        };
66
        nodes.push(node);
67
        prevNode = node;
68
        continue;
69
      }
70
 
71
      if (prevNode && isDupOf(prevNode.text, text)) {
72
        if (text.length > prevNode.text.length) {
73
          prevNode.text = text;
74
          prevNode.title = makeTitle(text);
75
          prevNode.kind = prevNode.kind === KIND.ROOT ? KIND.ROOT : classifyOne(text, prompt, true);
76
          prevNode.chars = text.length;
77
        }
78
        mergeActions(prevNode, prompt);
79
        continue;
80
      }
81
 
82
      if (prevNode && isRerunOf(prevNode.text, text)) {
83
        prevNode.reruns = (prevNode.reruns || 0) + 1;
84
        prevNode.text = text;
85
        prevNode.title = makeTitle(text);
86
        mergeActions(prevNode, prompt);
87
        continue;
88
      }
89
 
90
      if (
91
        prevNode &&
92
        words.length <= MAX_NUDGE_WORDS &&
93
        CONTINUATION_RE.test(text)
94
      ) {
95
        prevNode.nudges++;
96
        mergeActions(prevNode, prompt);
97
        continue;
98
      }
99
 
100
      if (words.length <= 6 && IGNORE_RE.test(text)) continue;
101
 
102
      const selection = rootAssigned && SELECTION_RE.exec(text);
103
      const node = selection ? {
104
        id: null,
105
        uuid: prompt.uuid,
106
        parentUuid: prompt.parentUuid,
107
        sessionId: session.sessionId,
108
        ts: prompt.ts,
109
        text,
110
        title: `Chose option ${selection[1].toUpperCase()} from the proposed menu`,
111
        kind: KIND.DIRECTION,
112
        status: 'accepted',
113
        nudges: 0,
114
        afterInterruption: prompt.afterInterruption,
115
        actions: prompt.actions || [],
116
        thinking: prompt.thinking || 0,
117
        rejections: prompt.rejections || [],
118
        chars: text.length,
119
      } : {
120
        id: null,
121
        uuid: prompt.uuid,
122
        parentUuid: prompt.parentUuid,
123
        sessionId: session.sessionId,
124
        ts: prompt.ts,
125
        text,
126
        title: makeTitle(text),
127
        kind: classifyOne(text, prompt, rootAssigned),
128
        status: 'accepted',
129
        nudges: 0,
130
        afterInterruption: prompt.afterInterruption,
131
        actions: prompt.actions || [],
132
        thinking: prompt.thinking || 0,
133
        rejections: prompt.rejections || [],
134
        chars: text.length,
135
        _priorTokens: prompt._priorTokens || null,
136
        structuralRedirect: prompt.structuralRedirect === true,
137
      };
138
      if (node.kind === KIND.ROOT) rootAssigned = true;
139
      nodes.push(node);
140
      prevNode = node;
141
    }
142
  }
143
  return nodes;
144
}
145
 
146
function makeRejectionTitle(rejections) {
147
  if (!Array.isArray(rejections) || !rejections.length) return '[agent action rejected]';
148
  const kinds = [...new Set(rejections.map((r) => r.kind))];
149
  if (kinds.length === 1) {
150
    const k = kinds[0].replace(/_/g, ' ');
151
    return `Agent action rejected (${k})`;
152
  }
153
  return `Agent action rejected (${kinds.length} kinds)`;
154
}
155
 
156
function isDupOf(a, b) {
157
  const na = a.replace(/\s+/g, ' ').trim();
158
  const nb = b.replace(/\s+/g, ' ').trim();
159
  if (na === nb) return true;
160
  const [short, long] = na.length <= nb.length ? [na, nb] : [nb, na];
161
  if (short.length < 24) return false;
162
 
163
  return long.startsWith(short.slice(0, short.length - 4));
164
}
165
 
166
function isRerunOf(a, b) {
167
  const na = a.replace(/\s+/g, ' ').trim();
168
  const nb = b.replace(/\s+/g, ' ').trim();
169
  if (na.length < 40 || nb.length < 40) return false;
170
  if (na.slice(0, 24) !== nb.slice(0, 24)) return false;
171
 
172
  if (na.startsWith('/') && na.slice(0, 32) === nb.slice(0, 32)) return true;
173
  const limit = Math.min(na.length, nb.length);
174
  let common = 0;
175
  while (common < limit && na[common] === nb[common]) common++;
176
  return common / limit >= 0.5;
177
}
178
 
179
function classifyOne(text, prompt, rootAssigned) {
180
  if (!rootAssigned) return KIND.ROOT;
181
  if (prompt && prompt.structuralRedirect) return KIND.CORRECTION;
182
  if (CORRECTION_STRONG_OPENERS.test(text) || CORRECTION_ANYWHERE.test(text)) return KIND.CORRECTION;
183
  if (SCOPE_ANYWHERE.test(text)) return KIND.SCOPE;
184
  if (CHECKPOINT_ANYWHERE.test(text)) return KIND.CHECKPOINT;
185
  if (CORRECTION_SOFT_OPENERS.test(text)) return KIND.CORRECTION;
186
  if (QUESTION_ONLY.test(text) && text.length < 250) return KIND.QUESTION;
187
  return KIND.DIRECTION;
188
}
189
 
190
export function makeTitle(text) {
191
  const firstLine = text.split(/\r?\n/).find((l) => l.trim()) || text;
192
  const sentence = firstLine.split(/(?<=[.!?])\s+/)[0] || firstLine;
193
  return truncate(sentence, 96);
194
}
195
 
196
function mergeActions(node, prompt) {
197
  node.actions = node.actions || [];
198
  if (prompt.actions && prompt.actions.length) node.actions.push(...prompt.actions);
199
  if (prompt.thinking) node.thinking = (node.thinking || 0) + prompt.thinking;
200
  if (Array.isArray(prompt.rejections) && prompt.rejections.length) {
201
    node.rejections = node.rejections || [];
202
    node.rejections.push(...prompt.rejections);
203
  }
204
}
205
 
206
export { KIND };