terminal-ai-helper 2.0.1 → 2.2.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/VERSION +1 -1
- package/install.sh +12 -4
- package/package.json +1 -1
- package/plugins/bash/index.bash +2 -2
- package/plugins/bash/keys.bash +15 -2
- package/plugins/bash/lookup.bash +54 -0
- package/plugins/zsh/index.zsh +2 -2
- package/plugins/zsh/lookup.zsh +84 -0
- package/plugins/zsh/menu.zsh +13 -1
- package/readme.md +7 -14
- package/tai/cli.py +65 -22
- package/tai/index.py +17 -3
- package/tai/paths.py +55 -0
- package/tai/predictor.py +20 -1
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.0
|
|
1
|
+
2.2.0
|
package/install.sh
CHANGED
|
@@ -23,11 +23,19 @@ else
|
|
|
23
23
|
exit 1
|
|
24
24
|
}
|
|
25
25
|
if [ -d "$TAI_DIR/.git" ]; then
|
|
26
|
-
# The one-liner is also the update path: an existing checkout is
|
|
27
|
-
#
|
|
28
|
-
#
|
|
26
|
+
# The one-liner is also the update path: an existing checkout is refetched
|
|
27
|
+
# and reinstalled, never cloned over. The checkout is this install's own —
|
|
28
|
+
# nobody develops in it — so the update is one commit deep every time:
|
|
29
|
+
# fetch --depth 1, reset to what origin named, prune what is left behind.
|
|
30
|
+
# A pull here would download every commit made since the checkout was cut,
|
|
31
|
+
# and a checkout that predates --depth 1 would carry its whole history
|
|
32
|
+
# forever. Offline, the fetch fails and set -e stops the install loudly —
|
|
33
|
+
# the old rule stands: never install old code on the strength of a guess.
|
|
29
34
|
echo "→ updating the checkout at $TAI_DIR…"
|
|
30
|
-
git -C "$TAI_DIR"
|
|
35
|
+
_branch="$(git -C "$TAI_DIR" rev-parse --abbrev-ref HEAD)"
|
|
36
|
+
git -C "$TAI_DIR" fetch --depth 1 --quiet origin "$_branch"
|
|
37
|
+
git -C "$TAI_DIR" reset --hard --quiet FETCH_HEAD
|
|
38
|
+
git -C "$TAI_DIR" gc --prune=now --quiet 2>/dev/null || true
|
|
31
39
|
elif [ -e "$TAI_DIR" ]; then
|
|
32
40
|
echo "tai: $TAI_DIR exists and is not a tai checkout — remove it or set TAI_DIR" >&2
|
|
33
41
|
exit 1
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "terminal-ai-helper",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "The terminal that knows your next command Bash/Zsh suggestions that learn from your own history Offline and private with no account required",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/plugins/bash/index.bash
CHANGED
|
@@ -24,7 +24,7 @@ _TAI_INDEX_STAMP="${_TAI_INDEX_FILE}.stamp"
|
|
|
24
24
|
# prints "value too great for base" on every keystroke instead of looking
|
|
25
25
|
# anything up. A shell started before the first `tai refresh` still has to
|
|
26
26
|
# be quiet and still gets the installed-command fallback.
|
|
27
|
-
declare -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE
|
|
27
|
+
declare -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE _TAI_LIVE
|
|
28
28
|
|
|
29
29
|
# Is this file the index, or something that only looks like it?
|
|
30
30
|
#
|
|
@@ -69,7 +69,7 @@ _tai_load_index() {
|
|
|
69
69
|
# installed for the life of the shell — the same rule as plugins/tai.zsh, and
|
|
70
70
|
# for the same reason: the file has to be the whole truth rather than a growing
|
|
71
71
|
# superset of every index this shell has ever read.
|
|
72
|
-
_TAI_SCORE=() _TAI_FIRST=() _TAI_WORD=() _TAI_SEQ=() _TAI_FILE=()
|
|
72
|
+
_TAI_SCORE=() _TAI_FIRST=() _TAI_WORD=() _TAI_SEQ=() _TAI_FILE=() _TAI_LIVE=()
|
|
73
73
|
# The sorted-keys array is built on first use, not here: its one fork (a
|
|
74
74
|
# `sort` over every first-word key) belonged to the shell's startup, which
|
|
75
75
|
# is exactly the cost a plugin should not add — and most keystrokes never
|
package/plugins/bash/keys.bash
CHANGED
|
@@ -201,7 +201,12 @@ _tai_complete() {
|
|
|
201
201
|
for c in "${_TAI_LEARNED_LINES[@]}"; do
|
|
202
202
|
# Folded, like the lookup that produced them: `ls down` offers
|
|
203
203
|
# `ls Downloads`.
|
|
204
|
-
[[ "${c,,}" == "${line,,}"* ]]
|
|
204
|
+
[[ "${c,,}" == "${line,,}"* ]] || continue
|
|
205
|
+
# A whole learned line is committed whole, so every word in it that the
|
|
206
|
+
# index marked as a relative path has to resolve here — the same
|
|
207
|
+
# question the ghost asks about the line it hints.
|
|
208
|
+
_tai_line_here "$c" 0 || continue
|
|
209
|
+
_tai_reply "$c"
|
|
205
210
|
done
|
|
206
211
|
fi
|
|
207
212
|
local -i names=0
|
|
@@ -211,7 +216,15 @@ _tai_complete() {
|
|
|
211
216
|
fi
|
|
212
217
|
local stem x
|
|
213
218
|
while IFS= read -r c; do
|
|
214
|
-
[[ -n "$c" && "${c,,}" == "${line,,}"* ]]
|
|
219
|
+
[[ -n "$c" && "${c,,}" == "${line,,}"* ]] || continue
|
|
220
|
+
stem="${c##*[[:space:]]}"
|
|
221
|
+
# The word this completion offers is judged where the shell stands: a
|
|
222
|
+
# learned word that is unambiguously a relative path (the index marked it
|
|
223
|
+
# in _TAI_LIVE) was true where it was recorded and may exist nowhere else.
|
|
224
|
+
# Flags and bare words are never marked; the file and directory sources
|
|
225
|
+
# above read the disk itself.
|
|
226
|
+
_tai_here "$stem" || continue
|
|
227
|
+
_tai_reply "$stem"
|
|
215
228
|
done <<< "$values"
|
|
216
229
|
if (( dir_arg )); then
|
|
217
230
|
local curw=""
|
package/plugins/bash/lookup.bash
CHANGED
|
@@ -161,6 +161,11 @@ _TAI_VALUES_ONE=0
|
|
|
161
161
|
# replaces a walk over 2,600 lines with two or three tests.
|
|
162
162
|
_tai_best() {
|
|
163
163
|
local values="$1" prefix="$2" best="" best_score=-1 c s cl pl="${2,,}"
|
|
164
|
+
# The words tai adds are the words judged: the ghost writes the whole line it
|
|
165
|
+
# hints, so every marked word after what the user typed has to resolve here,
|
|
166
|
+
# or the hint is an error the user is offered.
|
|
167
|
+
_tai_typed_words "$prefix"
|
|
168
|
+
local typed=$_TAI_TYPED_WORDS
|
|
164
169
|
# The extends-the-line and echo tests are case-insensitive — the lookup that
|
|
165
170
|
# filled `values` is, and the two have to agree about what extends what.
|
|
166
171
|
if [[ "${_TAI_VALUES_ONE:-0}" == "1" ]]; then
|
|
@@ -171,6 +176,7 @@ _tai_best() {
|
|
|
171
176
|
# what it finds, and a byte that slips through is an escape sequence on
|
|
172
177
|
# someone's terminal.
|
|
173
178
|
[[ "$c" =~ [[:cntrl:]] ]] && continue
|
|
179
|
+
_tai_line_here "$c" "$typed" || continue
|
|
174
180
|
best="$c"
|
|
175
181
|
break
|
|
176
182
|
done <<< "$values"
|
|
@@ -184,6 +190,7 @@ _tai_best() {
|
|
|
184
190
|
# ghost text: the byte goes to the terminal as an escape sequence. The
|
|
185
191
|
# store refuses at record time now, but this shell sources what it finds.
|
|
186
192
|
[[ "$c" =~ [[:cntrl:]] ]] && continue
|
|
193
|
+
_tai_line_here "$c" "$typed" || continue
|
|
187
194
|
s="${_TAI_SCORE[$c]:-0}"
|
|
188
195
|
if (( s > best_score )); then best="$c"; best_score="$s"; fi
|
|
189
196
|
done <<< "$values"
|
|
@@ -224,6 +231,53 @@ _tai_destination_live() {
|
|
|
224
231
|
[[ -d "$t" ]]
|
|
225
232
|
}
|
|
226
233
|
|
|
234
|
+
# The words the index marks in _TAI_LIVE are the learned words that are
|
|
235
|
+
# unambiguously *relative* filesystem paths — the one kind of word whose truth
|
|
236
|
+
# is a fact about the directory it is offered in. A flag is true everywhere and
|
|
237
|
+
# `~/.ollama` is true wherever this machine stands; `Data/INI` was true where it
|
|
238
|
+
# was recorded and may exist nowhere else, and offered from another directory it
|
|
239
|
+
# is the error the user reads the moment they take it. The shape test that
|
|
240
|
+
# decided what gets marked lives in tai/paths.py and is deliberately not
|
|
241
|
+
# re-implemented here — the shell only asks the one question left to it: does
|
|
242
|
+
# the word resolve where the user stands. One builtin stat, paid only on a word
|
|
243
|
+
# the index marked, on the paths that offer it.
|
|
244
|
+
_tai_here() {
|
|
245
|
+
[[ -n "${_TAI_LIVE[$1]:-}" ]] || return 0
|
|
246
|
+
[[ -e "$1" ]]
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
# The marked words *after* the $2 words already typed all resolve here. This is
|
|
250
|
+
# the question about a whole line — the ghost writes the rest of the line it
|
|
251
|
+
# hints and a first-word completion commits it — so the claim under test is not
|
|
252
|
+
# the next word but every word tai would add. The typed words are the user's
|
|
253
|
+
# own and get no opinion; a line that splits to fewer tokens than that adds
|
|
254
|
+
# nothing and passes. An empty _TAI_LIVE — an index that predates the map, or a
|
|
255
|
+
# history with no relative path in it — passes without a split at all.
|
|
256
|
+
_tai_line_here() {
|
|
257
|
+
local line="$1" n="$2" w
|
|
258
|
+
((${#_TAI_LIVE[@]})) || return 0
|
|
259
|
+
local -a t=()
|
|
260
|
+
read -ra t <<< "$line"
|
|
261
|
+
for w in "${t[@]:n}"; do
|
|
262
|
+
_tai_here "$w" || return 1
|
|
263
|
+
done
|
|
264
|
+
return 0
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
# How many words of `$1` are complete: a trailing space finishes the word before
|
|
268
|
+
# it, so `ls ` has one and `ls Da` still has only the command. Answered in
|
|
269
|
+
# _TAI_TYPED_WORDS and not in the return status, because a pasted line can carry
|
|
270
|
+
# more words than a status can hold and a wrapped count is a wrong count.
|
|
271
|
+
_TAI_TYPED_WORDS=0
|
|
272
|
+
_tai_typed_words() {
|
|
273
|
+
local -a t=()
|
|
274
|
+
read -ra t <<< "$1"
|
|
275
|
+
local n=${#t[@]}
|
|
276
|
+
[[ "$1" == *' ' ]] || (( n -= 1 ))
|
|
277
|
+
(( n < 0 )) && n=0
|
|
278
|
+
_TAI_TYPED_WORDS=$n
|
|
279
|
+
}
|
|
280
|
+
|
|
227
281
|
# Rank the line behind the wrapper and put the wrapper back. `sudo git ` completes
|
|
228
282
|
# from the `git ...` the user has run.
|
|
229
283
|
_tai_wrapped() {
|
package/plugins/zsh/index.zsh
CHANGED
|
@@ -15,7 +15,7 @@ _TAI_INDEX_STAMP="${_TAI_INDEX_FILE}.stamp"
|
|
|
15
15
|
# Declare the maps even when the index file is missing, so a shell started before
|
|
16
16
|
# the first `tai refresh` is quiet and still gets the installed-command
|
|
17
17
|
# fallback.
|
|
18
|
-
typeset -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE
|
|
18
|
+
typeset -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE _TAI_LIVE
|
|
19
19
|
|
|
20
20
|
# Is this file the index, or something that only looks like it?
|
|
21
21
|
#
|
|
@@ -63,7 +63,7 @@ _tai_load_index() {
|
|
|
63
63
|
# suggestion from the store, the next prompt re-read the index, and the stale
|
|
64
64
|
# key was still there. Clearing first is what makes the file the whole truth
|
|
65
65
|
# rather than a growing superset of every index this shell has ever seen.
|
|
66
|
-
_TAI_SCORE=() _TAI_FIRST=() _TAI_WORD=() _TAI_SEQ=() _TAI_FILE=()
|
|
66
|
+
_TAI_SCORE=() _TAI_FIRST=() _TAI_WORD=() _TAI_SEQ=() _TAI_FILE=() _TAI_LIVE=()
|
|
67
67
|
# A lookup may cache an answer about the lines it was asked from, so a reload
|
|
68
68
|
# has to retire those answers: bumping the generation is what makes a cached
|
|
69
69
|
# answer speak about the index that is installed now rather than the one that
|
package/plugins/zsh/lookup.zsh
CHANGED
|
@@ -64,6 +64,61 @@ _TAI_CTRL=$'[\x01-\x1f\x7f]'
|
|
|
64
64
|
# shared by hundreds of commands, where the answer is a guess either way.
|
|
65
65
|
typeset -gi _TAI_PREFIX_KEYS=64
|
|
66
66
|
|
|
67
|
+
# The words the index marks in _TAI_LIVE are the learned words that are
|
|
68
|
+
# unambiguously *relative* filesystem paths — the one kind of word whose truth
|
|
69
|
+
# is a fact about the directory it is offered in. A flag is true everywhere and
|
|
70
|
+
# `~/.ollama` is true wherever this machine stands; `Data/INI` was true where it
|
|
71
|
+
# was recorded and may exist nowhere else, and offered from another directory it
|
|
72
|
+
# is the error the user reads the moment they take it. The shape test that
|
|
73
|
+
# decided what gets marked lives in tai/paths.py and is deliberately not
|
|
74
|
+
# re-implemented here — the shell only asks the one question left to it: does
|
|
75
|
+
# the word resolve where the user stands. One builtin stat, paid only on a word
|
|
76
|
+
# the index marked, on the paths that offer it.
|
|
77
|
+
_tai_here() {
|
|
78
|
+
(( ${+_TAI_LIVE[$1]} )) || return 0
|
|
79
|
+
[[ -e "$1" ]]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# The marked words *after* the $2 words already typed all resolve here. This is
|
|
83
|
+
# the question about a whole line — the ghost writes the rest of the line it
|
|
84
|
+
# hints, a glimpse pick replaces the line, a first-word menu commits it — so the
|
|
85
|
+
# claim under test is not the next word but every word tai would add. The typed
|
|
86
|
+
# words are the user's own and get no opinion; a line that splits to fewer
|
|
87
|
+
# tokens than that adds nothing and passes. An empty _TAI_LIVE — an index that
|
|
88
|
+
# predates the map, or a history with no relative path in it — passes without a
|
|
89
|
+
# split at all.
|
|
90
|
+
_tai_line_here() {
|
|
91
|
+
local line="$1" w
|
|
92
|
+
integer n="$2"
|
|
93
|
+
(( ${#_TAI_LIVE} )) || return 0
|
|
94
|
+
local -a t
|
|
95
|
+
t=( ${=line} )
|
|
96
|
+
# zsh's own slice, not the bash-style `${t[@]:n}`: the colon there is a
|
|
97
|
+
# subscript flag the parser reads as a modifier and errors on.
|
|
98
|
+
for w in "${(@)t[n + 1, -1]}"; do
|
|
99
|
+
_tai_here "$w" || return 1
|
|
100
|
+
done
|
|
101
|
+
return 0
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# How many words of `$1` are complete: a trailing space finishes the word before
|
|
105
|
+
# it, so `ls ` has one and `ls Da` still has only the command. The count the
|
|
106
|
+
# line question needs, so the words tai adds are exactly the words judged.
|
|
107
|
+
# Answered in _TAI_TYPED_WORDS and not in the return status, because a pasted
|
|
108
|
+
# line can carry more words than a status can hold and a wrapped count is a
|
|
109
|
+
# wrong count. The name is not the widgets' `_TAI_TYPED` — that one is the
|
|
110
|
+
# typing flag the arrow-down gate reads, and a lookup clobbering it on every
|
|
111
|
+
# keystroke was breaking the glimpse it opens.
|
|
112
|
+
_tai_typed_words() {
|
|
113
|
+
local -a t
|
|
114
|
+
t=( ${=1} )
|
|
115
|
+
integer n=${#t}
|
|
116
|
+
[[ "$1" == *' ' ]] || (( n -= 1 ))
|
|
117
|
+
(( n < 0 )) && n=0
|
|
118
|
+
_TAI_TYPED_WORDS=$n
|
|
119
|
+
}
|
|
120
|
+
typeset -gi _TAI_TYPED_WORDS=0
|
|
121
|
+
|
|
67
122
|
# The candidate lines for a command name — the word before the first space — into
|
|
68
123
|
# _TAI_LINES, newline-joined the way the index stores them.
|
|
69
124
|
#
|
|
@@ -288,6 +343,19 @@ _tai_lines() {
|
|
|
288
343
|
# _TAI_PREFIX_KEYS of them.
|
|
289
344
|
_tai_best() {
|
|
290
345
|
local prefix="$1" c line best="" best_score=-1 s
|
|
346
|
+
integer typed=0 leadw=0
|
|
347
|
+
# The words tai adds are the words judged: a candidate is hinted whole, so
|
|
348
|
+
# every marked word after what the user typed has to resolve here, or the
|
|
349
|
+
# hint is an error the user is offered. The lead carries the wrapper's words
|
|
350
|
+
# — typed by the user, absent from the stored line — so they come off the
|
|
351
|
+
# count before the test.
|
|
352
|
+
_tai_typed_words "$prefix"
|
|
353
|
+
typed=$_TAI_TYPED_WORDS
|
|
354
|
+
local -a lt
|
|
355
|
+
lt=( ${=_TAI_LINES_LEAD} )
|
|
356
|
+
leadw=${#lt}
|
|
357
|
+
(( typed -= leadw ))
|
|
358
|
+
(( typed < 0 )) && typed=0
|
|
291
359
|
# The extends-the-line and echo tests are case-insensitive — the lookup that
|
|
292
360
|
# filled _TAI_LINES is, and the two have to agree about what extends what.
|
|
293
361
|
# `(#i)` needs extendedglob; localoptions hands this shell its own options
|
|
@@ -298,6 +366,7 @@ _tai_best() {
|
|
|
298
366
|
for c in "${_TAI_LINES[@]}"; do
|
|
299
367
|
line="$_TAI_LINES_LEAD$c"
|
|
300
368
|
[[ -z "$c" || "$line" == (#i)"$prefix" ]] && continue
|
|
369
|
+
_tai_line_here "$c" "$typed" || continue
|
|
301
370
|
best="$line"
|
|
302
371
|
break
|
|
303
372
|
done
|
|
@@ -305,6 +374,7 @@ _tai_best() {
|
|
|
305
374
|
for c in "${_TAI_LINES[@]}"; do
|
|
306
375
|
line="$_TAI_LINES_LEAD$c"
|
|
307
376
|
[[ -z "$c" || "$line" == (#i)"$prefix" || "$line" != (#i)"$prefix"* ]] && continue
|
|
377
|
+
_tai_line_here "$c" "$typed" || continue
|
|
308
378
|
s="${_TAI_SCORE[$c]:-0}"
|
|
309
379
|
if (( s > best_score )); then best="$c"; best_score="$s"; fi
|
|
310
380
|
done
|
|
@@ -457,6 +527,20 @@ _tai_query_do() {
|
|
|
457
527
|
done
|
|
458
528
|
_TAI_LOOSE_LINES=( "${live_l[@]}" )
|
|
459
529
|
fi
|
|
530
|
+
# The same judgement for every other line the glimpse holds, because a pick
|
|
531
|
+
# here replaces the whole typed line: a relative path in a glimpse line was
|
|
532
|
+
# true where the line was recorded, and from this directory it can be the
|
|
533
|
+
# error the user is handed instead of a command. The cd lines above get the
|
|
534
|
+
# stricter destination test; these get the words test — the same rule, at
|
|
535
|
+
# the depth each line claims. The list is filtered fresh per query rather
|
|
536
|
+
# than inside the glimpse, whose cache outlives the directory.
|
|
537
|
+
local -a here_l
|
|
538
|
+
local gline
|
|
539
|
+
here_l=()
|
|
540
|
+
for gline in "${_TAI_LOOSE_LINES[@]}"; do
|
|
541
|
+
_tai_line_here "$gline" 0 && here_l+=( "$gline" )
|
|
542
|
+
done
|
|
543
|
+
_TAI_LOOSE_LINES=( "${here_l[@]}" )
|
|
460
544
|
fi
|
|
461
545
|
|
|
462
546
|
# A learned cd destination was judged for liveness where it was recorded,
|
package/plugins/zsh/menu.zsh
CHANGED
|
@@ -271,6 +271,10 @@ _tai_menu_first_lines() {
|
|
|
271
271
|
sorted=( "${(O)merged[@]}" )
|
|
272
272
|
local m
|
|
273
273
|
for m in "${(@)sorted[1,_TAI_MENU_FIRST_CAP]}"; do
|
|
274
|
+
# A whole line is committed whole, so every marked word in it is judged —
|
|
275
|
+
# the same question the ghost asks about the line it hints, answered the
|
|
276
|
+
# same way, so Tab and the grey text cannot disagree about what works here.
|
|
277
|
+
_tai_line_here "${m[10,-1]}" 0 || continue
|
|
274
278
|
_TAI_MENU_LEARNED+=( "${m[10,-1]}" )
|
|
275
279
|
done
|
|
276
280
|
(( ${#_TAI_MENU_LEARNED[@]} ))
|
|
@@ -365,7 +369,15 @@ _tai_menu_open() {
|
|
|
365
369
|
for c in "${_TAI_LINES[@]}"; do
|
|
366
370
|
(( ${#c} > skip )) || continue
|
|
367
371
|
rest="$_TAI_LINES_LEAD${c}"
|
|
368
|
-
|
|
372
|
+
c="${${rest[skip + 1, -1]}%% *}"
|
|
373
|
+
# The word the menu completes is judged where the menu stands: a learned
|
|
374
|
+
# word that is unambiguously a relative path (the index marked it in
|
|
375
|
+
# _TAI_LIVE) was true where it was recorded and may exist nowhere else,
|
|
376
|
+
# and Enter on it reads "no such file or directory". Flags and bare words
|
|
377
|
+
# are never marked; the file and glob sources below are the disk itself.
|
|
378
|
+
[[ -n "$c" ]] || continue
|
|
379
|
+
_tai_here "$c" || continue
|
|
380
|
+
out+=( "$c" ); outq+=( 0 )
|
|
369
381
|
done
|
|
370
382
|
|
|
371
383
|
# 1b. The first word has no `before` for the lookup above, and without this
|
package/readme.md
CHANGED
|
@@ -28,20 +28,6 @@ curl -fsSL https://raw.githubusercontent.com/mlibre/Terminal-AI-Helper/main/inst
|
|
|
28
28
|
exec zsh
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
Or using npm
|
|
32
|
-
|
|
33
|
-
```sh
|
|
34
|
-
npm i -g terminal-ai-helper@latest --allow-scripts=terminal-ai-helper
|
|
35
|
-
exec zsh
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
The flag is npm's: newer npm blocks install scripts by default, and without
|
|
39
|
-
it the package unpacks and nothing is wired — no `tai` command, no rc lines.
|
|
40
|
-
|
|
41
|
-
The line downloads TAI to `~/.tai` and runs the installer from there; run it
|
|
42
|
-
again later and it updates in place. It enables both shells, imports your
|
|
43
|
-
history, and builds the first index:
|
|
44
|
-
|
|
45
31
|
```text
|
|
46
32
|
→ cloning tai to /home/you/.tai…
|
|
47
33
|
→ learning from your history, building the index…
|
|
@@ -62,6 +48,13 @@ that switched doors could leave the shell's `source` line naming the
|
|
|
62
48
|
older tree — code updates never reach it; `tai update` retargets that
|
|
63
49
|
line, and `tai version` says where it points.
|
|
64
50
|
|
|
51
|
+
Or using npm
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
npm i -g terminal-ai-helper@latest --allow-scripts=terminal-ai-helper
|
|
55
|
+
exec zsh
|
|
56
|
+
```
|
|
57
|
+
|
|
65
58
|
## Commands
|
|
66
59
|
|
|
67
60
|
```sh
|
package/tai/cli.py
CHANGED
|
@@ -266,6 +266,7 @@ def cmd_update(a) -> int:
|
|
|
266
266
|
|
|
267
267
|
import shutil
|
|
268
268
|
import subprocess
|
|
269
|
+
from pathlib import Path
|
|
269
270
|
|
|
270
271
|
git = shutil.which("git")
|
|
271
272
|
if not git:
|
|
@@ -281,15 +282,49 @@ def cmd_update(a) -> int:
|
|
|
281
282
|
return subprocess.run([git, "-C", str(repo), "rev-parse", "--short", "HEAD"],
|
|
282
283
|
capture_output=True, text=True).stdout.strip()
|
|
283
284
|
|
|
285
|
+
def run_git(*argv: str) -> subprocess.CompletedProcess:
|
|
286
|
+
return subprocess.run([git, "-C", str(repo), *argv],
|
|
287
|
+
capture_output=True, text=True)
|
|
288
|
+
|
|
289
|
+
# The checkout the one-liner cloned is this install's own tree, not a
|
|
290
|
+
# development branch: nobody commits in it, so the update is a fetch one
|
|
291
|
+
# commit deep and a reset to what origin named — the same three commands
|
|
292
|
+
# install.sh's piped door runs. A pull here would download every commit
|
|
293
|
+
# made since the checkout was cut, and one that predates --depth 1 would
|
|
294
|
+
# carry its whole history forever. A checkout anywhere else is somebody's
|
|
295
|
+
# tree — maybe a fork they develop in — and it keeps the careful pull:
|
|
296
|
+
# fast-forward only, a divergence fails loudly rather than resetting
|
|
297
|
+
# anybody's work away.
|
|
298
|
+
managed = (repo.resolve()
|
|
299
|
+
== Path(os.environ.get("TAI_DIR")
|
|
300
|
+
or Path.home() / ".tai").resolve())
|
|
301
|
+
|
|
284
302
|
before = head()
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
303
|
+
if managed:
|
|
304
|
+
branch = run_git("rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
|
|
305
|
+
failed = None
|
|
306
|
+
for argv in (("fetch", "--depth", "1", "--quiet", "origin", branch),
|
|
307
|
+
("reset", "--hard", "--quiet", "FETCH_HEAD")):
|
|
308
|
+
done = run_git(*argv)
|
|
309
|
+
if done.returncode != 0:
|
|
310
|
+
failed = argv[0]
|
|
311
|
+
break
|
|
312
|
+
if failed:
|
|
313
|
+
print(f"tai: git {failed} failed:")
|
|
314
|
+
print((done.stderr or done.stdout).strip())
|
|
315
|
+
return 1
|
|
316
|
+
# Hygiene, not a step: what the old history weighed is already
|
|
317
|
+
# unreachable after the reset, and a failed prune costs nothing.
|
|
318
|
+
run_git("gc", "--prune=now", "--quiet")
|
|
319
|
+
else:
|
|
320
|
+
pull = run_git("pull", "--ff-only")
|
|
321
|
+
if pull.returncode != 0:
|
|
322
|
+
# A local edit in the checkout is the usual cause, and git's own
|
|
323
|
+
# message says exactly what to do about it, so pass it through
|
|
324
|
+
# rather than guess.
|
|
325
|
+
print("tai: git pull failed:")
|
|
326
|
+
print((pull.stderr or pull.stdout).strip())
|
|
327
|
+
return 1
|
|
293
328
|
after = head()
|
|
294
329
|
# Flushed, because the installer below writes to the same terminal from a
|
|
295
330
|
# child process and would otherwise appear ahead of this line.
|
|
@@ -587,21 +622,29 @@ def cmd_uninstall(a) -> int:
|
|
|
587
622
|
removed.append(str(rc))
|
|
588
623
|
# The piped door clones the code itself to TAI_DIR (default ~/.tai), and
|
|
589
624
|
# leaving it behind would leave the program installed in every way but the
|
|
590
|
-
# wrapper — the same rule the rc blocks above are held to.
|
|
591
|
-
#
|
|
592
|
-
#
|
|
593
|
-
#
|
|
594
|
-
#
|
|
595
|
-
#
|
|
625
|
+
# wrapper — the same rule the rc blocks above are held to. The name is the
|
|
626
|
+
# identity: the path is tai's namespace, created by tai's installer and
|
|
627
|
+
# claimed by nothing else, so whatever the checkout has grown into — a
|
|
628
|
+
# shape an older door left half-made, files the user dropped beside the
|
|
629
|
+
# code — goes with the uninstall, and the report says which kind it was.
|
|
630
|
+
# One floor stands: never a directory the home lives in or under, so a
|
|
631
|
+
# mistyped TAI_DIR cannot name more than tai owns. Last on purpose: this
|
|
632
|
+
# command usually runs from inside the checkout (the wrapper execs this
|
|
633
|
+
# very file), so removing it has to be the last thing the run does.
|
|
596
634
|
checkout = Path(os.environ.get("TAI_DIR") or home / ".tai")
|
|
597
|
-
|
|
635
|
+
held = ""
|
|
598
636
|
if checkout.exists():
|
|
599
|
-
if (
|
|
600
|
-
|
|
601
|
-
shutil.rmtree(checkout)
|
|
602
|
-
removed.append(str(checkout))
|
|
637
|
+
if home.is_relative_to(checkout):
|
|
638
|
+
held = f"left {checkout} — it holds the home directory"
|
|
603
639
|
else:
|
|
604
|
-
|
|
640
|
+
ours = ((checkout / "tai" / "cli.py").exists()
|
|
641
|
+
and (checkout / "install.sh").exists())
|
|
642
|
+
if checkout.is_dir():
|
|
643
|
+
shutil.rmtree(checkout)
|
|
644
|
+
else:
|
|
645
|
+
checkout.unlink()
|
|
646
|
+
removed.append(str(checkout) + ("" if ours
|
|
647
|
+
else " (not a tai checkout — removed anyway)"))
|
|
605
648
|
# The npm door's code is npm's to remove, and the running tree says the
|
|
606
649
|
# same thing the wrapper did — this process may itself be executing from
|
|
607
650
|
# the package npm owns. Last, for the same reason the checkout is: the
|
|
@@ -622,10 +665,10 @@ def cmd_uninstall(a) -> int:
|
|
|
622
665
|
print("tai removed" if removed else "tai was not installed")
|
|
623
666
|
for path in removed:
|
|
624
667
|
print(f" removed {path}")
|
|
625
|
-
if left:
|
|
626
|
-
print(f" left {left} — not a tai checkout")
|
|
627
668
|
if npm_note:
|
|
628
669
|
print(f" {npm_note}")
|
|
670
|
+
if held:
|
|
671
|
+
print(f" {held}")
|
|
629
672
|
return 0
|
|
630
673
|
|
|
631
674
|
|
package/tai/index.py
CHANGED
|
@@ -11,6 +11,8 @@ Each snapshot stores:
|
|
|
11
11
|
_TAI_WORD[word-prefix] newline-separated candidates
|
|
12
12
|
_TAI_SEQ[previous] newline-separated candidates
|
|
13
13
|
_TAI_FILE[line-without-its-last-word] 1, when that word is a file
|
|
14
|
+
_TAI_LIVE[word] 1, when the word is a relative path the shell
|
|
15
|
+
must find where it stands before offering it
|
|
14
16
|
|
|
15
17
|
Commands are escaped as POSIX single-quoted strings, which zsh accepts.
|
|
16
18
|
"""
|
|
@@ -25,7 +27,7 @@ from pathlib import Path
|
|
|
25
27
|
from tai.engine import (Engine, LEN_PENALTY_DIV, MAX_LEN_PEN, W_FREQ,
|
|
26
28
|
W_RECENCY, W_SUCCESS, W_INSTALLED)
|
|
27
29
|
from tai.knowledge import load_all as load_tool_knowledge
|
|
28
|
-
from tai.paths import destinations_first, takes_file
|
|
30
|
+
from tai.paths import destinations_first, relative_word_set, takes_file
|
|
29
31
|
from tai.seed import SEED_COMMANDS
|
|
30
32
|
from tai.store import load_rows
|
|
31
33
|
from tai.typo import shadow_map
|
|
@@ -445,13 +447,21 @@ def build(max_commands: int = 20000) -> int:
|
|
|
445
447
|
if head:
|
|
446
448
|
file_keys.add(head)
|
|
447
449
|
|
|
450
|
+
# The words the shells must ask the filesystem about before offering them:
|
|
451
|
+
# unambiguously relative paths, by the shape test paths.py already owns.
|
|
452
|
+
# Everything else is either not a path (flags, subcommands, bare words a
|
|
453
|
+
# `git` may mean), cwd-independent (`~`-paths and absolutes, judged at build
|
|
454
|
+
# by the stale set), or unjudgeable — and the module's rule is that an
|
|
455
|
+
# ambiguous token gets no opinion.
|
|
456
|
+
live = relative_word_set(cmds)
|
|
457
|
+
|
|
448
458
|
zsh_file = index_path()
|
|
449
459
|
zsh_file.parent.mkdir(parents=True, exist_ok=True)
|
|
450
460
|
bash_file = bash_index_path()
|
|
451
461
|
tmp = zsh_file.with_suffix(".tmp")
|
|
452
462
|
with tmp.open("w", encoding="utf-8") as f:
|
|
453
463
|
f.write("# tai embedded zsh index; generated, do not edit\n")
|
|
454
|
-
f.write("typeset -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE\n")
|
|
464
|
+
f.write("typeset -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE _TAI_LIVE\n")
|
|
455
465
|
# `_TAI_X+=(key value)` — a pair appended to an associative array, one
|
|
456
466
|
# builtin call per entry and no function call at all.
|
|
457
467
|
#
|
|
@@ -473,12 +483,14 @@ def build(max_commands: int = 20000) -> int:
|
|
|
473
483
|
f.write(f"_TAI_SEQ+=({_zq(key)} {_zq(chr(10).join(values))})\n")
|
|
474
484
|
for key in sorted(file_keys):
|
|
475
485
|
f.write(f"_TAI_FILE+=({_zq(key)} 1)\n")
|
|
486
|
+
for word in sorted(live):
|
|
487
|
+
f.write(f"_TAI_LIVE+=({_zq(word)} 1)\n")
|
|
476
488
|
os.replace(tmp, zsh_file)
|
|
477
489
|
|
|
478
490
|
bash_tmp = bash_file.with_suffix(".tmp")
|
|
479
491
|
with bash_tmp.open("w", encoding="utf-8") as f:
|
|
480
492
|
f.write("# tai embedded bash index; generated, do not edit\n")
|
|
481
|
-
f.write("declare -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE\n")
|
|
493
|
+
f.write("declare -gA _TAI_SCORE _TAI_FIRST _TAI_WORD _TAI_SEQ _TAI_FILE _TAI_LIVE\n")
|
|
482
494
|
for score, cmd in ranked:
|
|
483
495
|
f.write(f"_TAI_SCORE[{_zq(cmd)}]={score}\n")
|
|
484
496
|
for key, values in sorted(first.items()):
|
|
@@ -489,6 +501,8 @@ def build(max_commands: int = 20000) -> int:
|
|
|
489
501
|
f.write(f"_TAI_SEQ[{_zq(key)}]={_zq(chr(10).join(values))}\n")
|
|
490
502
|
for key in sorted(file_keys):
|
|
491
503
|
f.write(f"_TAI_FILE[{_zq(key)}]=1\n")
|
|
504
|
+
for word in sorted(live):
|
|
505
|
+
f.write(f"_TAI_LIVE[{_zq(word)}]=1\n")
|
|
492
506
|
os.replace(bash_tmp, bash_file)
|
|
493
507
|
_compile_zsh_index()
|
|
494
508
|
return len(cmds)
|
package/tai/paths.py
CHANGED
|
@@ -459,3 +459,58 @@ def stale_in(engine, cwd: str = "") -> set[str]:
|
|
|
459
459
|
return stale_commands(
|
|
460
460
|
((cmd, list(st.cwd)) for cmd, st in engine.cmds.items()),
|
|
461
461
|
cwd)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def relative_word_set(cmds) -> frozenset[str]:
|
|
465
|
+
"""The learned words that are unambiguously *relative* filesystem paths.
|
|
466
|
+
|
|
467
|
+
The one kind of word whose truth is a fact about the directory it is
|
|
468
|
+
offered in. A flag is true everywhere; `~/.ollama` and `/usr/bin` are true
|
|
469
|
+
wherever this machine stands, and were already judged at build; `Data/INI`
|
|
470
|
+
was true where it was recorded and may exist nowhere else — offered from
|
|
471
|
+
another directory it is the error the user is shown the moment they take
|
|
472
|
+
it. The shape test is this module's, unquoted first: reimplementing it in
|
|
473
|
+
two shells is how `git diff ~main` and `date 1/2/2024` come to be judged
|
|
474
|
+
as paths. The index writes the set out as _TAI_LIVE, and the shells ask
|
|
475
|
+
the filesystem the one question a marked word costs.
|
|
476
|
+
"""
|
|
477
|
+
out: set[str] = set()
|
|
478
|
+
for cmd in cmds:
|
|
479
|
+
for token in (cmd or "").split():
|
|
480
|
+
token = _unquote(token)
|
|
481
|
+
if token.startswith("~") or token.startswith("/"):
|
|
482
|
+
continue
|
|
483
|
+
if is_path_like(token):
|
|
484
|
+
out.add(token)
|
|
485
|
+
return frozenset(out)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def alive_in(cmd: str, cwd: str = "") -> bool:
|
|
489
|
+
"""True when every relative path in `cmd` resolves in `cwd`.
|
|
490
|
+
|
|
491
|
+
`command_verdict` above asks whether a command was ever true: a row
|
|
492
|
+
recorded in a directory that still holds the path survives. This asks the
|
|
493
|
+
other question, the one the user answers with Enter: is it true *here*,
|
|
494
|
+
in the directory the suggestion is being shown in? A `ls Data/INI`
|
|
495
|
+
recorded in the directory that has Data/INI is alive by the first answer
|
|
496
|
+
and dead by the second everywhere else, and it was the second answer the
|
|
497
|
+
report was about.
|
|
498
|
+
|
|
499
|
+
Every judgement is a stat against `cwd`, never a removal decision about a
|
|
500
|
+
stored row — this gates what is shown, and the recorded evidence above
|
|
501
|
+
stays the only thing that can retire a row. No directory, no opinion.
|
|
502
|
+
"""
|
|
503
|
+
if not enabled():
|
|
504
|
+
return True
|
|
505
|
+
cwd = cwd or os.getcwd()
|
|
506
|
+
if not cmd or not os.path.isdir(cwd):
|
|
507
|
+
return True
|
|
508
|
+
for token in cmd.split():
|
|
509
|
+
token = _unquote(token)
|
|
510
|
+
if not is_path_like(token):
|
|
511
|
+
continue
|
|
512
|
+
if token.startswith("~") or os.path.isabs(token):
|
|
513
|
+
continue
|
|
514
|
+
if not os.path.exists(os.path.join(cwd, token)):
|
|
515
|
+
return False
|
|
516
|
+
return True
|
package/tai/predictor.py
CHANGED
|
@@ -14,7 +14,7 @@ import time
|
|
|
14
14
|
|
|
15
15
|
from tai.engine import WRAPPERS, Engine
|
|
16
16
|
from tai.fresh import files
|
|
17
|
-
from tai.paths import db_path, takes_file
|
|
17
|
+
from tai.paths import alive_in, db_path, takes_file
|
|
18
18
|
|
|
19
19
|
_E: Engine | None = None
|
|
20
20
|
_STALE: frozenset = frozenset()
|
|
@@ -95,6 +95,25 @@ def suggest(prefix: str = "", cwd: str = "", repo: str = "",
|
|
|
95
95
|
res = _E.suggest(prefix=prefix or "", cwd=cwd, repo=repo, branch=branch,
|
|
96
96
|
last_commands=last_commands or [], limit=limit, temp=temp,
|
|
97
97
|
exclude=_STALE, on_path=_on_path)
|
|
98
|
+
# The stale set above never hides a relative path that was true where it
|
|
99
|
+
# was recorded — which is most of them, and that is right for a snapshot
|
|
100
|
+
# shown in every directory. This answer is shown in one directory, so its
|
|
101
|
+
# lines are judged there too: a line whose relative path does not resolve
|
|
102
|
+
# where the answer will be taken is not an answer, and Enter on it is the
|
|
103
|
+
# error the user reads. The same rule the index writes into _TAI_LIVE for
|
|
104
|
+
# the shells, from the same module, so the rooms cannot disagree about
|
|
105
|
+
# what works where the user stands.
|
|
106
|
+
_where = cwd or os.getcwd()
|
|
107
|
+
_all = res.get("choices", [])
|
|
108
|
+
_live = [c for c in _all if alive_in(c["cmd"], _where)]
|
|
109
|
+
if len(_live) != len(_all):
|
|
110
|
+
res["choices"] = _live
|
|
111
|
+
res["probabilities"] = {c["cmd"]: c["prob"] for c in _live}
|
|
112
|
+
_best = _live[0]["cmd"] if _live else ""
|
|
113
|
+
res["choice"] = _best
|
|
114
|
+
res["completion"] = (_best[len(prefix or ""):]
|
|
115
|
+
if _best.lower().startswith((prefix or "").lower())
|
|
116
|
+
else "")
|
|
98
117
|
res = _file_answer(prefix or "", res)
|
|
99
118
|
# The engine measures its own ranking; a caller that only cares about the
|
|
100
119
|
# whole answer wants the process time, and a sub-millisecond rank has no
|